From 007f35e4706ee56a63da36fd0e89d60c3a2faf8a Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:51:45 -0700 Subject: [PATCH 01/26] Revert "feat(orcad): source-side dormant export of a relay-hosted SSH target (#16741 T6-8) (#24519)" This reverts commit 783101b3045848d5f9b5eeefe4a699487310521e. --- src/main/durable-file-write.ts | 13 +- .../profile-session-owner-transfer.ts | 28 -- src/main/orcad-migration-export-holds.test.ts | 152 --------- .../orcad/orcad-migration-manifest-digest.ts | 18 - .../session-snapshot-operations.ts | 1 - .../loading-store/store-domain-composition.ts | 12 +- .../loading-store/store-runtime-state.ts | 2 - .../loading-store/terminal-session-cleanup.ts | 6 +- .../workspace-session-snapshot-publication.ts | 20 +- .../orcad-destination-catalog-projection.ts | 9 - ...orcad-scrollback-snapshot-transfer.test.ts | 231 ------------- .../orcad-scrollback-snapshot-transfer.ts | 302 ----------------- .../orcad-source-automation-state.ts | 187 ---------- .../orcad-source-catalog.ts | 57 ---- .../orcad-source-client-browser-intents.ts | 73 ---- .../orcad-source-client-state.test.ts | 282 --------------- .../orcad-source-client-state.ts | 258 -------------- .../orcad-source-dependency-census.test.ts | 320 ------------------ .../orcad-source-dependency-census.ts | 257 -------------- .../orcad-source-dormant-state.ts | 273 --------------- .../orcad-source-export.test.ts | 127 ------- .../orcad-source-export.ts | 128 ------- .../orcad-source-retired-worktree-names.ts | 81 ----- .../orcad-source-scope.test.ts | 58 ---- .../orcad-source-scope.ts | 60 ---- .../orcad-source-scrollback-state.test.ts | 39 --- .../orcad-source-scrollback-state.ts | 158 --------- .../orcad-source-session-dependencies.ts | 145 -------- ...ad-source-workspace-session-eligibility.ts | 245 -------------- ...rcad-source-workspace-session-fragments.ts | 113 ------- .../orcad-source-workspace-session-layout.ts | 64 ---- .../orcad-source-workspace-session.ts | 115 ------- .../orcad-source-worktree-metadata.ts | 122 ------- .../orcad-migration-manifest-export.test.ts | 168 --------- .../ssh/orcad-migration-manifest-export.ts | 77 ----- ...cad-migration-snapshot-coordinator.test.ts | 171 ---------- .../orcad-migration-snapshot-coordinator.ts | 124 ------- src/main/ssh/ssh-target-orcad-claims.ts | 12 +- src/main/ssh/ssh-target-orcad-dependents.ts | 44 +-- .../ssh/ssh-target-orcad-preflight.test.ts | 159 --------- src/main/ssh/ssh-target-orcad-preflight.ts | 79 ----- ...nal-scrollback-snapshot-async-migration.ts | 5 +- src/main/terminal-scrollback-snapshots.ts | 37 +- src/main/worktree-retirement-namespace.ts | 2 +- ...workspace-session-terminal-buffers.test.ts | 36 +- .../workspace-session-terminal-buffers.ts | 15 +- 46 files changed, 30 insertions(+), 4855 deletions(-) delete mode 100644 src/main/orcad-migration-export-holds.test.ts delete mode 100644 src/main/orcad/orcad-migration-manifest-digest.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-destination-catalog-projection.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-retired-worktree-names.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts delete mode 100644 src/main/ssh/orcad-migration-manifest-export.test.ts delete mode 100644 src/main/ssh/orcad-migration-manifest-export.ts delete mode 100644 src/main/ssh/orcad-migration-snapshot-coordinator.test.ts delete mode 100644 src/main/ssh/orcad-migration-snapshot-coordinator.ts delete mode 100644 src/main/ssh/ssh-target-orcad-preflight.test.ts delete mode 100644 src/main/ssh/ssh-target-orcad-preflight.ts diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index e573b4ecd7f4..05e849327b7f 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -29,8 +29,7 @@ async function syncDirectory(directory: string): Promise { } } -/** Sync variant of the best-effort directory fsync, for callers that publish by rename or link. */ -export function syncDirectoryDurablySync(directory: string): void { +function syncDirectorySync(directory: string): void { let fd: number | null = null try { fd = openSync(directory, 'r') @@ -51,7 +50,7 @@ export function syncDirectoryDurablySync(directory: string): void { /** Rename an already-fsynced file and make the containing directory durable. */ export function renameDurableSync(tmpPath: string, finalPath: string): void { renameFileWithWindowsRetry(tmpPath, finalPath) - syncDirectoryDurablySync(dirname(finalPath)) + syncDirectorySync(dirname(finalPath)) } /** Publish an already-fsynced file without replacing a concurrently created destination. */ @@ -59,7 +58,7 @@ export function publishFileDurableSync(tmpPath: string, finalPath: string): bool if (!publishFileWithoutOverwrite(tmpPath, finalPath)) { return false } - syncDirectoryDurablySync(dirname(finalPath)) + syncDirectorySync(dirname(finalPath)) rmSync(tmpPath) return true } @@ -210,14 +209,12 @@ export async function removeStaleDurableWriteTempFiles( export function writeFileDurableSync( tmpPath: string, finalPath: string, - payload: string | Uint8Array, - /** Creation mode for a new file, e.g. 0o600 for state other users must not read. */ - mode?: number + payload: string | Uint8Array ): void { let renamed = false try { // A Uint8Array payload is written verbatim; a string still defaults to UTF-8. - writeFileSync(tmpPath, payload, mode === undefined ? undefined : { mode }) + writeFileSync(tmpPath, payload) const fd = openSync(tmpPath, 'r+') try { fsyncSync(fd) diff --git a/src/main/orca-profiles/profile-session-owner-transfer.ts b/src/main/orca-profiles/profile-session-owner-transfer.ts index ada6d099c795..040cf921f35a 100644 --- a/src/main/orca-profiles/profile-session-owner-transfer.ts +++ b/src/main/orca-profiles/profile-session-owner-transfer.ts @@ -1,5 +1,4 @@ import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' import type { BrowserPage, BrowserWorkspace } from '../../shared/browser-workspace-types' import { remapBrowserPageDocLocation } from '../../shared/browser-page-doc-location' import type { Tab, TabGroup } from '../../shared/tab-types' @@ -12,24 +11,9 @@ import { isWorkspaceKey } from '../../shared/workspace-scope' import { SESSION_FIELDS_COPIED_BY_OWNER_KEY } from './profile-project-session-field-disposition' import { mapMarkdownFrontmatterVisible } from './profile-session-markdown-transfer' -export { - buildMarkdownFrontmatterIdMap, - markdownFileIdCandidates -} from './profile-session-markdown-transfer' - export type SessionOwnerProjection = { mapOwnerKey: (ownerKey: string) => string | null mapWorktreeId: (worktreeId: string) => string - /** Keeps a sleeping agent's resume record when it can still resume after the transfer. */ - projectSleepingAgentSession?: ( - record: SleepingAgentSessionRecord - ) => SleepingAgentSessionRecord | null - /** Projects source-partition focus scalars when the selected entities are dormant. */ - projectSessionFocus?: (args: { - source: WorkspaceSessionState - transferred: WorkspaceSessionState - terminalTabIds: ReadonlySet - }) => void } export function extractSessionOwnersForTransfer( @@ -137,18 +121,6 @@ export function extractSessionOwnersForTransfer( : [] ) ) - projection.projectSessionFocus?.({ source, transferred, terminalTabIds }) - if (projection.projectSleepingAgentSession) { - const sleepingAgentSessionsByPaneKey = Object.fromEntries( - Object.entries(source.sleepingAgentSessionsByPaneKey ?? {}).flatMap(([paneKey, record]) => { - const projected = projection.projectSleepingAgentSession?.(record) - return projected ? [[paneKey, projected] as const] : [] - }) - ) - if (Object.keys(sleepingAgentSessionsByPaneKey).length > 0) { - transferred.sleepingAgentSessionsByPaneKey = sleepingAgentSessionsByPaneKey - } - } transferred.activeWorktreeIdsOnShutdown = source.activeWorktreeIdsOnShutdown ?.filter((worktreeId) => projection.mapOwnerKey(worktreeId) !== null) .map(projection.mapWorktreeId) diff --git a/src/main/orcad-migration-export-holds.test.ts b/src/main/orcad-migration-export-holds.test.ts deleted file mode 100644 index 53c5b0e1ee2c..000000000000 --- a/src/main/orcad-migration-export-holds.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { mkdtempSync, rmSync, statSync, readFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { getDefaultWorkspaceSession } from '../shared/constants' -import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' -import { writeFileDurableSync } from './durable-file-write' -import { - getTerminalScrollbackSnapshotPath, - readTerminalScrollbackStoredBytesSync, - writeTerminalScrollbackSnapshotSync -} from './terminal-scrollback-snapshots' -import { deleteRemovedTerminalScrollbackSnapshots } from './persistence/loading-store/terminal-session-cleanup' -import { deleteRemovedTerminalScrollbackSnapshotsAsync } from './terminal-scrollback-snapshot-async-migration' -import { extractSessionOwnersForTransfer } from './orca-profiles/profile-session-owner-transfer' -import type { SleepingAgentSessionRecord } from '../shared/agent-session-resume' - -const roots: string[] = [] -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function storage() { - const root = mkdtempSync(join(tmpdir(), 'orcad-export-holds-')) - roots.push(root) - return { root, storage: { snapshotRoot: join(root, 'terminal-scrollback') } } -} - -function sessionWithRef(ref: string | null): WorkspaceSessionState { - return { - ...getDefaultWorkspaceSession(), - terminalLayoutsByTabId: ref - ? { - 'tab-1': { - root: null, - activeLeafId: null, - expandedLeafId: null, - scrollbackRefsByLeafId: { 'leaf-1': ref } - } - } - : {} - } -} - -describe('scrollback store reads and retention for migration export', () => { - it('reads stored bytes for a ref and none for an unknown one', () => { - const { storage: store } = storage() - const ref = writeTerminalScrollbackSnapshotSync({ - tabId: 'tab-1', - leafId: 'leaf-1', - buffer: 'saved output', - storage: store - }) - expect(ref).not.toBeNull() - expect(readTerminalScrollbackStoredBytesSync(ref ?? '', store)?.toString('utf8')).toBe( - 'saved output' - ) - expect(readTerminalScrollbackStoredBytesSync(`v1-${'0'.repeat(32)}`, store)).toBeNull() - }) - - it.each(['sync', 'async'] as const)( - 'keeps a %s-removed snapshot a pending export still reads', - async (mode) => { - const { storage: store } = storage() - const ref = - writeTerminalScrollbackSnapshotSync({ - tabId: 'tab-1', - leafId: 'leaf-1', - buffer: 'retained', - storage: store - }) ?? '' - const path = getTerminalScrollbackSnapshotPath(ref, store) ?? '' - const retained = new Set([ref]) - if (mode === 'sync') { - deleteRemovedTerminalScrollbackSnapshots( - sessionWithRef(ref), - sessionWithRef(null), - store, - retained - ) - } else { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - sessionWithRef(ref), - sessionWithRef(null), - store, - retained - ) - } - expect(readFileSync(path, 'utf8')).toBe('retained') - deleteRemovedTerminalScrollbackSnapshots(sessionWithRef(ref), sessionWithRef(null), store) - expect(() => statSync(path)).toThrow() - } - ) -}) - -describe.skipIf(process.platform === 'win32')('durable writes with a creation mode', () => { - it('creates the file with the requested mode', () => { - const { root } = storage() - const finalPath = join(root, 'state.json') - writeFileDurableSync(`${finalPath}.tmp`, finalPath, '{}', 0o600) - expect(statSync(finalPath).mode & 0o777).toBe(0o600) - }) -}) - -describe('session owner projection hooks', () => { - function sleeping(paneKey: string): SleepingAgentSessionRecord { - return { - paneKey, - worktreeId: 'repo-1::/srv/app', - agent: 'claude', - providerSession: { key: 'session_id', id: paneKey }, - prompt: 'resume me', - state: 'done', - capturedAt: 1, - updatedAt: 1 - } - } - - it('lets a transfer keep resumable sleeping agents and project focus scalars', () => { - const source: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - sleepingAgentSessionsByPaneKey: { - 'tab-1:leaf-1': sleeping('tab-1:leaf-1'), - 'tab-2:leaf-1': sleeping('tab-2:leaf-1') - } - } - const projectSessionFocus = vi.fn() - const transferred = extractSessionOwnersForTransfer(source, { - mapOwnerKey: (ownerKey) => ownerKey, - mapWorktreeId: (id) => id, - projectSleepingAgentSession: (record) => (record.paneKey === 'tab-1:leaf-1' ? record : null), - projectSessionFocus - }) - expect(Object.keys(transferred.sleepingAgentSessionsByPaneKey ?? {})).toEqual(['tab-1:leaf-1']) - expect(projectSessionFocus).toHaveBeenCalledWith( - expect.objectContaining({ source, transferred }) - ) - }) - - it('drops sleeping agents when the projection does not opt in', () => { - const transferred = extractSessionOwnersForTransfer( - { - ...getDefaultWorkspaceSession(), - sleepingAgentSessionsByPaneKey: { 'tab-1:leaf-1': sleeping('tab-1:leaf-1') } - }, - { mapOwnerKey: (ownerKey) => ownerKey, mapWorktreeId: (id) => id } - ) - expect(transferred.sleepingAgentSessionsByPaneKey).toBeUndefined() - }) -}) diff --git a/src/main/orcad/orcad-migration-manifest-digest.ts b/src/main/orcad/orcad-migration-manifest-digest.ts deleted file mode 100644 index f9031ceffc7b..000000000000 --- a/src/main/orcad/orcad-migration-manifest-digest.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createHash } from 'node:crypto' -import { - orcadMigrationManifestHashInput, - type OrcadMigrationManifest -} from '../../shared/orcad-migration-manifest' - -export function computeOrcadMigrationManifestSha256( - manifest: Omit -): string { - return createHash('sha256').update(orcadMigrationManifestHashInput(manifest)).digest('hex') -} - -export function assertOrcadMigrationManifestDigest(manifest: OrcadMigrationManifest): void { - const { manifestSha256, ...unsigned } = manifest - if (computeOrcadMigrationManifestSha256(unsigned) !== manifestSha256) { - throw new Error('orcad_migration_manifest_digest_mismatch') - } -} diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts index a5a2d557c473..9c3cf23be7b7 100644 --- a/src/main/persistence/loading-store/session-snapshot-operations.ts +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -24,7 +24,6 @@ type SessionSnapshotOperationsRuntime = Pick< | 'quitFlushStarted' | 'state' | 'terminalScrollbackSnapshotStorage' - | 'retainedScrollbackRefsByMigrationId' | 'writesFrozen' > diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index 437dced88387..70f22e90c5c7 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -63,10 +63,6 @@ import { SshLeaseRecoveryOperations, installSshLeaseRecoveryOperationsContext } from './ssh-lease-recovery-operations' -import { - OrcadSourceExportPersistence, - installOrcadSourceExportPersistenceContext -} from '../migrating-orcad-catalog/orcad-source-export' export type StoreDomainOperations = WriteSchedulingOperations & PrimaryStateWriteOperations & @@ -83,7 +79,6 @@ export type StoreDomainOperations = WriteSchedulingOperations & SshProfileOperations & RetiredWorktreeNamePersistence & SshLeaseRecoveryOperations & - OrcadSourceExportPersistence & WriteFlushBarrierOperations export type StoreDomains = { @@ -108,7 +103,6 @@ export type StoreDomains = { sshProfiles: SshProfileOperations retiredWorktreeNames: RetiredWorktreeNamePersistence sshLeases: SshLeaseRecoveryOperations - orcadSourceExport: OrcadSourceExportPersistence } export const STORE_DOMAIN_OPERATION_CLASSES = [ @@ -127,7 +121,6 @@ export const STORE_DOMAIN_OPERATION_CLASSES = [ SshProfileOperations, RetiredWorktreeNamePersistence, SshLeaseRecoveryOperations, - OrcadSourceExportPersistence, WriteFlushBarrierOperations ] as const @@ -147,7 +140,6 @@ export function installStoreDomainContexts(target: Store, domains: StoreDomains) installSshProfileOperationsContext(target, domains.sshProfiles) installRetiredWorktreeNamePersistenceContext(target, domains.retiredWorktreeNames) installSshLeaseRecoveryOperationsContext(target, domains.sshLeases) - installOrcadSourceExportPersistenceContext(target, domains.orcadSourceExport) installWriteFlushBarrierOperationsContext(target, domains.flushBarriers) } @@ -204,8 +196,6 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { ptyBindings, sshProfiles, retiredWorktreeNames, - sshLeases, - // Read-only: holds the runtime state and nothing that writes. - orcadSourceExport: new OrcadSourceExportPersistence(runtime) + sshLeases } } diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index 65ba17d74fc0..b29f49f95f90 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -44,8 +44,6 @@ export class StoreRuntimeState { automationListProjectionCache: AutomationListProjectionCache | null = null activeViewPreference!: ActiveViewPreference readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage - /** Scrollback refs each in-flight migration export still reads, keyed by migration id. */ - readonly retainedScrollbackRefsByMigrationId = new Map>() writeTimer: ReturnType | null = null pendingWrite: Promise | null = null pendingSnapshotFileWork: Promise | null = null diff --git a/src/main/persistence/loading-store/terminal-session-cleanup.ts b/src/main/persistence/loading-store/terminal-session-cleanup.ts index 7c9b3b10b11f..dede6bf8747d 100644 --- a/src/main/persistence/loading-store/terminal-session-cleanup.ts +++ b/src/main/persistence/loading-store/terminal-session-cleanup.ts @@ -27,16 +27,14 @@ export function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSess export function deleteRemovedTerminalScrollbackSnapshots( prior: WorkspaceSessionState | undefined, next: WorkspaceSessionState, - storage?: TerminalScrollbackSnapshotStorage, - /** Refs a pending migration export still reads; they outlive the session row. */ - retainedRefs: ReadonlySet = new Set() + storage?: TerminalScrollbackSnapshotStorage ): void { if (!prior) { return } const nextRefs = collectTerminalScrollbackSnapshotRefs(next) for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { - if (!nextRefs.has(ref) && !retainedRefs.has(ref)) { + if (!nextRefs.has(ref)) { deleteTerminalScrollbackSnapshotSync(ref, storage) } } diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts index 178f1d833ff7..96834ffbcf92 100644 --- a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -104,8 +104,7 @@ export function setLocalWorkspaceSession( deleteRemovedTerminalScrollbackSnapshots( prior, session, - context.runtime.terminalScrollbackSnapshotStorage, - retainedScrollbackRefs(context.runtime) + context.runtime.terminalScrollbackSnapshotStorage ) } context.runtime.state.workspaceSession = session @@ -141,8 +140,7 @@ export function enqueueTerminalScrollbackSnapshotWork( await deleteRemovedTerminalScrollbackSnapshotsAsync( prior, context.runtime.state.workspaceSession, - context.runtime.terminalScrollbackSnapshotStorage, - retainedScrollbackRefs(context.runtime) + context.runtime.terminalScrollbackSnapshotStorage ) } return @@ -161,16 +159,14 @@ export function enqueueTerminalScrollbackSnapshotWork( await deleteRemovedTerminalScrollbackSnapshotsAsync( migrated, current, - context.runtime.terminalScrollbackSnapshotStorage, - retainedScrollbackRefs(context.runtime) + context.runtime.terminalScrollbackSnapshotStorage ) } if (current) { await deleteRemovedTerminalScrollbackSnapshotsAsync( prior, current, - context.runtime.terminalScrollbackSnapshotStorage, - retainedScrollbackRefs(context.runtime) + context.runtime.terminalScrollbackSnapshotStorage ) } }) @@ -184,11 +180,3 @@ export function enqueueTerminalScrollbackSnapshotWork( }) context.runtime.pendingSnapshotFileWork = work } - -function retainedScrollbackRefs(runtime: { - retainedScrollbackRefsByMigrationId: ReadonlyMap> -}): ReadonlySet { - return new Set( - [...runtime.retainedScrollbackRefsByMigrationId.values()].flatMap((refs) => [...refs]) - ) -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-destination-catalog-projection.ts b/src/main/persistence/migrating-orcad-catalog/orcad-destination-catalog-projection.ts deleted file mode 100644 index 9e7811199ec3..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-destination-catalog-projection.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** How a source catalog row looks once a local orcad owns it: no SSH connection or host. */ -import type { Repo } from '../../../shared/repo-types' - -export function toOrcadDestinationRepository(source: Repo): Repo { - const destination = structuredClone(source) - delete destination.connectionId - delete destination.executionHostId - return destination -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts deleted file mode 100644 index 8e3efdeefe65..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from '../../../shared/constants' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - type OrcadMigrationManifest -} from '../../../shared/orcad-migration-manifest' -import type { - OrcadMigrationSnapshotChunkRequest, - OrcadMigrationTerminalScrollbackSnapshot -} from '../../../shared/orcad-migration-scrollback' -import { - getTerminalScrollbackSnapshotPath, - type TerminalScrollbackSnapshotStorage -} from '../../terminal-scrollback-snapshots' -import { - abortOrcadMigrationSnapshots, - assertOrcadMigrationSnapshotsReady, - commitOrcadMigrationSnapshots, - inspectOrcadMigrationSnapshotUploads, - pruneOrcadMigrationSnapshotStaging, - stageOrcadMigrationSnapshotChunk -} from './orcad-scrollback-snapshot-transfer' - -let root: string -let storage: TerminalScrollbackSnapshotStorage - -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orcad-migration-snapshots-')) - storage = { snapshotRoot: join(root, 'terminal-scrollback') } -}) - -afterEach(() => rmSync(root, { recursive: true, force: true })) - -describe('orcad scrollback snapshot transfer', () => { - it('resumes from staged byte length and accepts only exact idempotent retries', () => { - const bytes = Buffer.from('first line\nmultibyte: 🐋\nlast line', 'utf8') - const descriptor = snapshot('1', 'tab-1', 'leaf-1', bytes) - const manifest = migrationManifest('resume', [descriptor]) - const state = stagedState(manifest) - const first = bytes.subarray(0, 11) - - expect(stage(state, manifest, descriptor, 0, first).acknowledgedOffset).toBe(first.length) - expect(inspectOrcadMigrationSnapshotUploads(manifest, storage)?.[0]?.receivedBytes).toBe( - first.length - ) - expect(stage(state, manifest, descriptor, 0, first).acknowledgedOffset).toBe(first.length) - expect(() => stage(state, manifest, descriptor, 0, Buffer.from('different!!'))).toThrow( - 'orcad_migration_snapshot_retry_mismatch' - ) - expect(() => stage(state, manifest, descriptor, first.length + 1, Buffer.from('x'))).toThrow( - 'orcad_migration_snapshot_offset_invalid' - ) - - stage(state, manifest, descriptor, first.length, bytes.subarray(first.length)) - assertOrcadMigrationSnapshotsReady(manifest, storage) - commitOrcadMigrationSnapshots(manifest, storage) - expect(readFinal(descriptor)).toEqual(bytes) - }) - - it('refuses incomplete and digest-mismatched staged bytes', () => { - const bytes = Buffer.from('expected bytes', 'utf8') - const descriptor = snapshot('2', 'tab-2', 'leaf-2', bytes) - const manifest = migrationManifest('refuse', [descriptor]) - const state = stagedState(manifest) - - stage(state, manifest, descriptor, 0, bytes.subarray(0, 4)) - expect(() => assertOrcadMigrationSnapshotsReady(manifest, storage)).toThrow( - 'orcad_migration_snapshot_incomplete' - ) - stage(state, manifest, descriptor, 4, Buffer.alloc(bytes.length - 4, 0x78)) - expect(() => assertOrcadMigrationSnapshotsReady(manifest, storage)).toThrow( - 'orcad_migration_snapshot_digest_mismatch' - ) - expect(readFinal(descriptor)).toBeNull() - }) - - it('recognizes an already-materialized matching final file after restart', () => { - const bytes = Buffer.from('already committed bytes', 'utf8') - const descriptor = snapshot('3', 'tab-3', 'leaf-3', bytes) - const manifest = migrationManifest('materialized', [descriptor]) - stagedState(manifest) - writeFinal(descriptor, bytes) - - expect(inspectOrcadMigrationSnapshotUploads(manifest, storage)?.[0]?.receivedBytes).toBe( - bytes.length - ) - assertOrcadMigrationSnapshotsReady(manifest, storage) - commitOrcadMigrationSnapshots(manifest, storage) - expect(readFinal(descriptor)).toEqual(bytes) - }) - - it('rejects a same-ref content conflict before materializing any snapshot', () => { - const firstBytes = Buffer.from('first snapshot', 'utf8') - const secondBytes = Buffer.from('second snapshot', 'utf8') - const first = snapshot('4', 'tab-4', 'leaf-4', firstBytes) - const second = snapshot('5', 'tab-5', 'leaf-5', secondBytes) - const manifest = migrationManifest('conflict', [first, second]) - const state = stagedState(manifest) - stage(state, manifest, first, 0, firstBytes) - stage(state, manifest, second, 0, secondBytes) - writeFinal(second, Buffer.alloc(secondBytes.length, 0x78)) - - expect(() => commitOrcadMigrationSnapshots(manifest, storage)).toThrow( - `orcad_migration_snapshot_destination_conflict:${second.ref}` - ) - expect(readFinal(first)).toBeNull() - expect(readFinal(second)).toEqual(Buffer.alloc(secondBytes.length, 0x78)) - }) - - it('removes only the selected staging tree and prunes unjournaled trees', () => { - const bytes = Buffer.from('staged bytes', 'utf8') - const firstDescriptor = snapshot('6', 'tab-6', 'leaf-6', bytes) - const secondDescriptor = snapshot('7', 'tab-7', 'leaf-7', bytes) - const first = migrationManifest('abort-first', [firstDescriptor]) - const second = migrationManifest('retain-second', [secondDescriptor]) - const state = stagedState(first, second) - stage(state, first, firstDescriptor, 0, bytes) - stage(state, second, secondDescriptor, 0, bytes) - - abortOrcadMigrationSnapshots(first, storage) - expect(inspectOrcadMigrationSnapshotUploads(first, storage)?.[0]?.receivedBytes).toBe(0) - expect(inspectOrcadMigrationSnapshotUploads(second, storage)?.[0]?.receivedBytes).toBe( - bytes.length - ) - pruneOrcadMigrationSnapshotStaging( - state.filter((entry) => entry.migrationId !== second.migrationId), - storage - ) - expect(inspectOrcadMigrationSnapshotUploads(second, storage)?.[0]?.receivedBytes).toBe(0) - }) -}) - -function snapshot( - suffix: string, - tabId: string, - leafId: string, - bytes: Buffer -): OrcadMigrationTerminalScrollbackSnapshot { - return { - tabId, - leafId, - ref: `v1-${suffix.repeat(32)}`, - sha256: createHash('sha256').update(bytes).digest('hex'), - byteLength: bytes.length - } -} - -function migrationManifest( - migrationId: string, - snapshots: OrcadMigrationTerminalScrollbackSnapshot[] -): OrcadMigrationManifest { - const workspaceSession = getDefaultWorkspaceSession() - workspaceSession.terminalLayoutsByTabId = Object.fromEntries( - snapshots.map((entry) => [ - entry.tabId, - { - root: { type: 'leaf' as const, leafId: entry.leafId }, - activeLeafId: entry.leafId, - expandedLeafId: null, - scrollbackRefsByLeafId: { [entry.leafId]: entry.ref } - } - ]) - ) - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId, - createdAt: '2026-08-30T12:00:00.000Z', - source: { sshTargetId: 'source', sshTargetGeneration: 1, targetLabel: 'Source' }, - payload: { - repositories: [], - projectGroups: [], - folderWorkspaces: [], - dormantState: { - version: 1, - worktreeMeta: [], - worktreeLineage: [], - workspaceLineage: [], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [], - workspaceSession, - terminalScrollbackSnapshots: snapshots - } - }, - manifestSha256: 'a'.repeat(64) - } -} - -/** The manifests the destination importer holds staged. */ -function stagedState(...manifests: OrcadMigrationManifest[]): OrcadMigrationManifest[] { - return manifests -} - -function stage( - state: OrcadMigrationManifest[], - manifest: OrcadMigrationManifest, - descriptor: OrcadMigrationTerminalScrollbackSnapshot, - offset: number, - bytes: Buffer -) { - const request: OrcadMigrationSnapshotChunkRequest = { - migrationId: manifest.migrationId, - manifestSha256: manifest.manifestSha256, - ref: descriptor.ref, - offset, - bytesBase64: bytes.toString('base64') - } - const stagedManifest = state.find((entry) => entry.migrationId === manifest.migrationId) ?? null - return stageOrcadMigrationSnapshotChunk({ stagedManifest, storage, request }) -} - -function writeFinal(descriptor: OrcadMigrationTerminalScrollbackSnapshot, bytes: Buffer): void { - const path = getTerminalScrollbackSnapshotPath(descriptor.ref, storage) - if (!path) { - throw new Error('expected snapshot path') - } - if (!storage.snapshotRoot) { - throw new Error('expected snapshot root') - } - mkdirSync(storage.snapshotRoot, { recursive: true }) - writeFileSync(path, bytes) -} - -function readFinal(descriptor: OrcadMigrationTerminalScrollbackSnapshot): Buffer | null { - const path = getTerminalScrollbackSnapshotPath(descriptor.ref, storage) - return path && existsSync(path) ? readFileSync(path) : null -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts b/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts deleted file mode 100644 index 80a7e566b719..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { createHash } from 'node:crypto' -import { - closeSync, - existsSync, - fsyncSync, - linkSync, - mkdirSync, - openSync, - readFileSync, - readSync, - readdirSync, - rmSync, - statSync, - writeSync -} from 'node:fs' -import { join } from 'node:path' -import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' -import { - decodeOrcadMigrationSnapshotChunk, - type OrcadMigrationSnapshotChunkRequest, - type OrcadMigrationSnapshotChunkResult, - type OrcadMigrationSnapshotUploadState, - type OrcadMigrationTerminalScrollbackSnapshot -} from '../../../shared/orcad-migration-scrollback' -import { syncDirectoryDurablySync } from '../../durable-file-write' -import { - getTerminalScrollbackSnapshotPath, - getTerminalScrollbackSnapshotRoot, - type TerminalScrollbackSnapshotStorage -} from '../../terminal-scrollback-snapshots' - -const STAGING_DIRECTORY = '.orcad-migration-staging' - -/** - * Destination side: append one verified chunk to the staging file for a snapshot the staged - * manifest names. `stagedManifest` is the manifest the importer accepted; a request for any - * other migration or digest is refused. Retries of an already-written range must match it. - */ -export function stageOrcadMigrationSnapshotChunk(args: { - stagedManifest: OrcadMigrationManifest | null - storage: TerminalScrollbackSnapshotStorage - request: OrcadMigrationSnapshotChunkRequest -}): OrcadMigrationSnapshotChunkResult { - const manifest = requireStagedManifest(args.stagedManifest, args.request) - const descriptor = requireDescriptor(manifest, args.request.ref) - const bytes = decodeOrcadMigrationSnapshotChunk(args.request.bytesBase64) - const path = stagedSnapshotPath(args.storage, manifest, descriptor) - mkdirSync(stagingDirectory(args.storage, manifest), { recursive: true, mode: 0o700 }) - const descriptorFd = openStagedFile(path) - try { - const size = statSync(path).size - if (args.request.offset > size || args.request.offset + bytes.length > descriptor.byteLength) { - throw new Error('orcad_migration_snapshot_offset_invalid') - } - if (args.request.offset < size) { - if (args.request.offset + bytes.length > size) { - throw new Error('orcad_migration_snapshot_offset_invalid') - } - const existing = Buffer.alloc(bytes.length) - const read = readSync(descriptorFd, existing, 0, existing.length, args.request.offset) - if (read !== bytes.length || !existing.equals(bytes)) { - throw new Error('orcad_migration_snapshot_retry_mismatch') - } - return chunkResult(args.request, size) - } - const written = writeSync(descriptorFd, bytes, 0, bytes.length, args.request.offset) - if (written !== bytes.length) { - throw new Error('orcad_migration_snapshot_write_incomplete') - } - fsyncSync(descriptorFd) - return chunkResult(args.request, size + bytes.length) - } finally { - closeSync(descriptorFd) - } -} - -export function inspectOrcadMigrationSnapshotUploads( - manifest: OrcadMigrationManifest, - storage: TerminalScrollbackSnapshotStorage -): OrcadMigrationSnapshotUploadState[] | undefined { - const snapshots = manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] - if (snapshots.length === 0) { - return undefined - } - return snapshots.map((descriptor) => ({ - ...descriptor, - receivedBytes: matchingFinalSnapshot(storage, descriptor) - ? descriptor.byteLength - : stagedSnapshotSize(storage, manifest, descriptor) - })) -} - -export function assertOrcadMigrationSnapshotsReady( - manifest: OrcadMigrationManifest, - storage: TerminalScrollbackSnapshotStorage -): void { - for (const upload of inspectOrcadMigrationSnapshotUploads(manifest, storage) ?? []) { - const finalStatus = inspectFinalSnapshot(storage, upload) - if (finalStatus === 'conflict') { - throw new Error(`orcad_migration_snapshot_destination_conflict:${upload.ref}`) - } - if (upload.receivedBytes !== upload.byteLength) { - throw new Error(`orcad_migration_snapshot_incomplete:${upload.ref}`) - } - const path = - finalStatus === 'matching' - ? getTerminalScrollbackSnapshotPath(upload.ref, storage) - : stagedSnapshotPath(storage, manifest, upload) - if (!path || !fileMatches(path, upload)) { - throw new Error(`orcad_migration_snapshot_digest_mismatch:${upload.ref}`) - } - } -} - -export function commitOrcadMigrationSnapshots( - manifest: OrcadMigrationManifest, - storage: TerminalScrollbackSnapshotStorage -): void { - const snapshots = manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] - assertOrcadMigrationSnapshotsReady(manifest, storage) - const root = getTerminalScrollbackSnapshotRoot(storage) - mkdirSync(root, { recursive: true, mode: 0o700 }) - for (const descriptor of snapshots) { - if (matchingFinalSnapshot(storage, descriptor)) { - rmSync(stagedSnapshotPath(storage, manifest, descriptor), { force: true }) - continue - } - const stagedPath = stagedSnapshotPath(storage, manifest, descriptor) - const finalPath = getTerminalScrollbackSnapshotPath(descriptor.ref, storage) - if (!finalPath) { - throw new Error('orcad_migration_snapshot_ref_invalid') - } - try { - linkSync(stagedPath, finalPath) - } catch (error) { - const finalStatus = inspectFinalSnapshot(storage, descriptor) - if (finalStatus === 'conflict') { - throw new Error(`orcad_migration_snapshot_destination_conflict:${descriptor.ref}`) - } - if (finalStatus !== 'matching') { - throw error - } - } - rmSync(stagedPath, { force: true }) - } - syncDirectoryDurablySync(root) - removeStagingDirectory(storage, manifest) -} - -export function abortOrcadMigrationSnapshots( - manifest: OrcadMigrationManifest, - storage: TerminalScrollbackSnapshotStorage -): void { - removeStagingDirectory(storage, manifest) -} - -/** Removes staging for every migration the importer no longer holds staged. */ -export function pruneOrcadMigrationSnapshotStaging( - stagedManifests: readonly OrcadMigrationManifest[], - storage: TerminalScrollbackSnapshotStorage -): void { - const root = join(getTerminalScrollbackSnapshotRoot(storage), STAGING_DIRECTORY) - const retained = new Set(stagedManifests.map(stagingKey)) - let entries: string[] - try { - entries = readdirSync(root) - } catch { - return - } - for (const entry of entries) { - if (/^[a-f0-9]{32}$/.test(entry) && !retained.has(entry)) { - rmSync(join(root, entry), { recursive: true, force: true }) - } - } -} - -function requireStagedManifest( - staged: OrcadMigrationManifest | null, - request: Pick -): OrcadMigrationManifest { - if ( - !staged || - staged.migrationId !== request.migrationId || - staged.manifestSha256 !== request.manifestSha256 - ) { - throw new Error('orcad_migration_snapshot_catalog_not_staged') - } - return staged -} - -function requireDescriptor( - manifest: OrcadMigrationManifest, - ref: string -): OrcadMigrationTerminalScrollbackSnapshot { - const descriptor = manifest.payload.dormantState?.terminalScrollbackSnapshots?.find( - (entry) => entry.ref === ref - ) - if (!descriptor) { - throw new Error('orcad_migration_snapshot_unknown') - } - return descriptor -} - -function stagedSnapshotSize( - storage: TerminalScrollbackSnapshotStorage, - manifest: OrcadMigrationManifest, - descriptor: OrcadMigrationTerminalScrollbackSnapshot -): number { - try { - return Math.min( - statSync(stagedSnapshotPath(storage, manifest, descriptor)).size, - descriptor.byteLength - ) - } catch { - return 0 - } -} - -function matchingFinalSnapshot( - storage: TerminalScrollbackSnapshotStorage, - descriptor: OrcadMigrationTerminalScrollbackSnapshot -): boolean { - return inspectFinalSnapshot(storage, descriptor) === 'matching' -} - -function inspectFinalSnapshot( - storage: TerminalScrollbackSnapshotStorage, - descriptor: OrcadMigrationTerminalScrollbackSnapshot -): 'absent' | 'matching' | 'conflict' { - const path = getTerminalScrollbackSnapshotPath(descriptor.ref, storage) - if (!path || !existsSync(path)) { - return 'absent' - } - return fileMatches(path, descriptor) ? 'matching' : 'conflict' -} - -function fileMatches(path: string, descriptor: OrcadMigrationTerminalScrollbackSnapshot): boolean { - try { - const bytes = readFileSync(path) - return ( - bytes.length === descriptor.byteLength && - createHash('sha256').update(bytes).digest('hex') === descriptor.sha256 - ) - } catch { - return false - } -} - -function openStagedFile(path: string): number { - try { - return openSync(path, 'r+') - } catch { - try { - return openSync(path, 'wx+', 0o600) - } catch { - return openSync(path, 'r+') - } - } -} - -function stagingDirectory( - storage: TerminalScrollbackSnapshotStorage, - manifest: OrcadMigrationManifest -): string { - return join(getTerminalScrollbackSnapshotRoot(storage), STAGING_DIRECTORY, stagingKey(manifest)) -} - -function stagedSnapshotPath( - storage: TerminalScrollbackSnapshotStorage, - manifest: OrcadMigrationManifest, - descriptor: OrcadMigrationTerminalScrollbackSnapshot -): string { - return join(stagingDirectory(storage, manifest), `${descriptor.ref}.${descriptor.sha256}.part`) -} - -function removeStagingDirectory( - storage: TerminalScrollbackSnapshotStorage, - manifest: OrcadMigrationManifest -): void { - const stagingRoot = join(getTerminalScrollbackSnapshotRoot(storage), STAGING_DIRECTORY) - rmSync(stagingDirectory(storage, manifest), { recursive: true, force: true }) - syncDirectoryDurablySync(stagingRoot) -} - -function stagingKey(manifest: OrcadMigrationManifest): string { - return createHash('sha256') - .update(`${manifest.migrationId}\0${manifest.manifestSha256}`) - .digest('hex') - .slice(0, 32) -} - -function chunkResult( - request: OrcadMigrationSnapshotChunkRequest, - acknowledgedOffset: number -): OrcadMigrationSnapshotChunkResult { - return { - migrationId: request.migrationId, - manifestSha256: request.manifestSha256, - ref: request.ref, - acknowledgedOffset - } -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts deleted file mode 100644 index e71292242d04..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { - isFinalAutomationRunStatus, - type Automation, - type AutomationRun -} from '../../../shared/automations-types' -import { getAutomationRunRepoId } from '../../../shared/automation-run-identity' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationManifestSource -} from '../../../shared/orcad-migration-manifest' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' -import { - createOrcadMigrationSourceScope, - orcadMigrationOwnerMatchesScope, - unqualifyOrcadMigrationOwnerKey, - type OrcadMigrationSourceScope -} from './orcad-source-scope' - -export type OrcadMigrationSourceAutomationInspection = { - automations: Automation[] - automationRuns: AutomationRun[] - blockedAutomationCount: number - blockedRunCount: number -} - -export function collectOrcadMigrationSourceAutomationState( - state: PersistedState, - source: OrcadMigrationManifestSource, - catalog: OrcadMigrationCatalogPayload -): OrcadMigrationSourceAutomationInspection { - const scope = createOrcadMigrationSourceScope({ source, catalog }) - const touchedAutomations = state.automations.filter((entry) => - automationTouchesScope(entry, scope) - ) - const touchedAutomationIds = new Set(touchedAutomations.map((entry) => entry.id)) - const touchedRuns = state.automationRuns.filter( - (entry) => touchedAutomationIds.has(entry.automationId) || runTouchesScope(entry, scope) - ) - const runsByAutomationId = Map.groupBy(touchedRuns, (entry) => entry.automationId) - const automationsById = Map.groupBy(touchedAutomations, (entry) => entry.id) - const automations: Automation[] = [] - const automationRuns: AutomationRun[] = [] - let blockedAutomationCount = 0 - let blockedRunCount = 0 - - for (const [automationId, matching] of automationsById) { - const runs = runsByAutomationId.get(automationId) ?? [] - const eligible = - matching.length === 1 && - automationCanTransfer(matching[0], scope) && - runs.every((run) => runCanTransfer(run, scope)) - if (!eligible) { - blockedAutomationCount += matching.length - blockedRunCount += runs.length - continue - } - automations.push(projectAutomationToDestination(matching[0])) - automationRuns.push(...runs.map(projectAutomationRunToDestination)) - } - - for (const [automationId, runs] of runsByAutomationId) { - if (!automationsById.has(automationId)) { - blockedRunCount += runs.length - } - } - automations.sort((left, right) => compareKeys(left.id, right.id)) - automationRuns.sort((left, right) => compareKeys(left.id, right.id)) - return { automations, automationRuns, blockedAutomationCount, blockedRunCount } -} - -function automationTouchesScope(automation: Automation, scope: OrcadMigrationSourceScope): boolean { - return ( - (automation.executionTargetType === 'ssh' && automation.executionTargetId === scope.targetId) || - (scope.targetGeneration !== null && - automation.executionTargetGeneration === scope.targetGeneration) || - scope.repoIds.has(getAutomationRunRepoId(automation)) || - orcadMigrationOwnerMatchesScope(automation.workspaceId, scope) || - contextTouchesScope(automation.runContext, scope) || - contextTouchesScope(automation.sourceContext, scope) - ) -} - -function runTouchesScope(run: AutomationRun, scope: OrcadMigrationSourceScope): boolean { - return ( - orcadMigrationOwnerMatchesScope(run.workspaceId, scope) || - contextTouchesScope(run.runContext, scope) || - contextTouchesScope(run.sourceContext, scope) - ) -} - -function automationCanTransfer(automation: Automation, scope: OrcadMigrationSourceScope): boolean { - return ( - automation.enabled === false && - automation.executionTargetType === 'ssh' && - automation.executionTargetId === scope.targetId && - automation.schedulerOwner === 'ssh_bridge' && - (automation.executionTargetGeneration === undefined || - automation.executionTargetGeneration === scope.targetGeneration) && - scope.repoIds.has(getAutomationRunRepoId(automation)) && - ownerCanTransfer(automation.workspaceId, scope) && - contextCanTransfer(automation.runContext, scope) && - contextCanTransfer(automation.sourceContext, scope) - ) -} - -function runCanTransfer(run: AutomationRun, scope: OrcadMigrationSourceScope): boolean { - return ( - isFinalAutomationRunStatus(run.status) && - ownerCanTransfer(run.workspaceId, scope) && - contextCanTransfer(run.runContext, scope) && - contextCanTransfer(run.sourceContext, scope) - ) -} - -function ownerCanTransfer(value: string | null, scope: OrcadMigrationSourceScope): boolean { - return value === null || orcadMigrationOwnerMatchesScope(value, scope) -} - -function contextTouchesScope( - context: WorkspaceRunContext | TaskSourceContext | null | undefined, - scope: OrcadMigrationSourceScope -): boolean { - return ( - context?.hostId === scope.hostId || - (typeof context?.repoId === 'string' && scope.repoIds.has(context.repoId)) - ) -} - -function contextCanTransfer( - context: WorkspaceRunContext | TaskSourceContext | null | undefined, - scope: OrcadMigrationSourceScope -): boolean { - if (!context) { - return true - } - return ( - context.hostId === scope.hostId && - typeof context.repoId === 'string' && - scope.repoIds.has(context.repoId) && - (!context.projectHostSetupId || context.projectHostSetupId === context.repoId) - ) -} - -function projectAutomationToDestination(source: Automation): Automation { - const destination: Automation = { - ...structuredClone(source), - runContext: projectContextToDestination(source.runContext), - sourceContext: projectContextToDestination(source.sourceContext), - executionTargetType: 'local', - executionTargetId: 'local', - schedulerOwner: 'remote_host_service', - workspaceId: projectOwnerToDestination(source.workspaceId) - } - delete destination.executionTargetGeneration - return destination -} - -function projectAutomationRunToDestination(source: AutomationRun): AutomationRun { - return { - ...structuredClone(source), - runContext: projectContextToDestination(source.runContext), - sourceContext: projectContextToDestination(source.sourceContext), - workspaceId: projectOwnerToDestination(source.workspaceId) - } -} - -function projectContextToDestination( - context: T | null | undefined -): T | null { - if (!context) { - return null - } - return Object.assign(structuredClone(context), { - hostId: LOCAL_EXECUTION_HOST_ID, - projectHostSetupId: context.repoId ?? null - }) -} - -function projectOwnerToDestination(value: string | null): string | null { - return value === null ? null : unqualifyOrcadMigrationOwnerKey(value) -} - -function compareKeys(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0 -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts deleted file mode 100644 index fc902a994c58..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { OrcadMigrationCatalogPayload } from '../../../shared/orcad-migration-manifest' -import type { ProjectGroup } from '../../../shared/project-group-types' -import type { SshTarget } from '../../../shared/ssh-types' -import type { Store } from '../../persistence' - -type OrcadSourceCatalogStore = Pick - -export function collectOrcadMigrationSourceCatalog( - store: OrcadSourceCatalogStore, - target: Pick -): OrcadMigrationCatalogPayload { - const repositories = store - .getRepos() - .filter((repo) => repo.connectionId === target.id) - .map((repo) => structuredClone(repo)) - const allGroups = store.getProjectGroups() - const groupConnectionById = new Map(allGroups.map((group) => [group.id, group.connectionId])) - const folderWorkspaces = store - .getFolderWorkspaces() - .filter( - (workspace) => - (workspace.connectionId ?? groupConnectionById.get(workspace.projectGroupId) ?? null) === - target.id - ) - .map((workspace) => structuredClone(workspace)) - const projectGroups = collectProjectGroups( - allGroups, - new Set([ - ...repositories.flatMap((repo) => (repo.projectGroupId ? [repo.projectGroupId] : [])), - ...folderWorkspaces.map((workspace) => workspace.projectGroupId), - ...allGroups.filter((group) => group.connectionId === target.id).map((group) => group.id) - ]) - ) - return { repositories, projectGroups, folderWorkspaces } -} - -function collectProjectGroups( - groups: ProjectGroup[], - initialIds: ReadonlySet -): ProjectGroup[] { - const byId = new Map(groups.map((group) => [group.id, group])) - const includedIds = new Set(initialIds) - const pending = [...initialIds] - while (pending.length > 0) { - const nextId = pending.pop() - if (!nextId) { - continue - } - const group = byId.get(nextId) - if (!group?.parentGroupId || includedIds.has(group.parentGroupId)) { - continue - } - includedIds.add(group.parentGroupId) - pending.push(group.parentGroupId) - } - return groups.filter((group) => includedIds.has(group.id)).map((group) => structuredClone(group)) -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts deleted file mode 100644 index b139d31d17cb..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { OrcadMigrationClientHostedBrowserCloseIntent } from '../../../shared/orcad-migration-client-state' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { - orcadMigrationOwnerMatchesScope, - unqualifyOrcadMigrationOwnerKey, - type OrcadMigrationSourceScope -} from './orcad-source-scope' - -export function collectCloseIntents( - state: PersistedState, - scope: OrcadMigrationSourceScope, - destinationEnvironmentId: string | undefined, - eligibleSession: WorkspaceSessionState | undefined, - onBlocked: () => void -): OrcadMigrationClientHostedBrowserCloseIntent[] | undefined { - const eligiblePages = new Set() - for (const [ownerKey, pages] of Object.entries( - eligibleSession?.clientHostedBrowserPagesByWorktree ?? {} - )) { - if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { - continue - } - const worktreeId = unqualifyOrcadMigrationOwnerKey(ownerKey) - for (const page of pages) { - eligiblePages.add(`${worktreeId}\0${page.browserPageId}`) - } - } - const result: OrcadMigrationClientHostedBrowserCloseIntent[] = [] - const seen = new Set() - const sessions = [ - state.workspaceSession, - ...Object.values(state.workspaceSessionsByHostId ?? {}).flatMap((entry) => - entry ? [entry] : [] - ) - ].filter((entry): entry is WorkspaceSessionState => Boolean(entry)) - for (const session of sessions) { - for (const [sourceEnvironmentId, intents] of Object.entries( - session.clientHostedBrowserCloseIntentsByEnvironment ?? {} - )) { - // Intents already keyed to the destination were handled by a prior retry. - if (sourceEnvironmentId === destinationEnvironmentId) { - continue - } - for (const intent of intents) { - if (!orcadMigrationOwnerMatchesScope(intent.worktreeId, scope)) { - continue - } - const worktreeId = unqualifyOrcadMigrationOwnerKey(intent.worktreeId) - const key = `${sourceEnvironmentId}\0${intent.browserPageId}\0${worktreeId}` - if ( - !eligiblePages.has(`${worktreeId}\0${intent.browserPageId}`) || - !destinationEnvironmentId - ) { - onBlocked() - continue - } - if (seen.has(key)) { - onBlocked() - continue - } - seen.add(key) - result.push({ - sourceEnvironmentId, - browserPageId: intent.browserPageId, - worktreeId, - closedAt: intent.closedAt - }) - } - } - } - return result.length > 0 ? result : undefined -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts deleted file mode 100644 index 8f3abb11ea60..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { OrcadMigrationCatalogPayload } from '../../../shared/orcad-migration-manifest' -import type { PersistedState } from '../../../shared/persisted-state-types' -import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' -import type { TerminalTab } from '../../../shared/terminal-tab-types' -import type { BrowserWorkspace } from '../../../shared/browser-workspace-types' -import { - CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, - type PersistedClientHostedBrowserPage -} from '../../../shared/client-hosted-browser-page-record' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { collectOrcadMigrationSourceClientState } from './orcad-source-client-state' - -const source = { - sshTargetId: 'target-1', - sshTargetGeneration: 3, - targetLabel: 'Build host' -} -const catalog: OrcadMigrationCatalogPayload = { - repositories: [ - { - id: 'repo-1', - path: '/srv/repo-1', - displayName: 'Repo', - badgeColor: '#737373', - addedAt: 1, - connectionId: source.sshTargetId, - executionHostId: 'ssh:target-1' - } - ], - projectGroups: [], - folderWorkspaces: [] -} - -function state(): PersistedState { - const persisted = getDefaultPersistedState('/home/test') - persisted.sshTargets = [ - { - id: source.sshTargetId, - label: source.targetLabel, - host: 'source.example.com', - port: 22, - username: 'deploy', - portForwards: [] - } - ] - return persisted -} - -function terminalTab(id: string, worktreeId: string): TerminalTab { - return { - id, - ptyId: null, - worktreeId, - title: id, - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } -} - -function session( - fields: Pick & Partial -): WorkspaceSessionState { - return { ...getDefaultWorkspaceSession(), tabGroups: {}, ...fields } -} - -function browserWorkspace(id: string, worktreeId: string): BrowserWorkspace { - return { - id, - worktreeId, - url: 'about:blank', - title: id, - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } -} - -function hostedPage(browserPageId: string, workspaceId: string): PersistedClientHostedBrowserPage { - return { - v: CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, - browserPageId, - workspaceId, - browserProfileId: 'default', - url: 'about:blank', - title: browserPageId, - pairedDeviceId: 'device-1', - savedAt: 1 - } -} - -describe('source client-state migration', () => { - it('rekeys representable mobile selections and desktop routing', () => { - const current = state() - current.mobileClientTabSelectionsByDeviceId = { - phone: { - 'ssh:target-1|repo-1::/srv/worktree': { - activeTabId: 'tab-1', - activeGroupId: null, - activeTabIdByGroupId: {} - } - } - } - current.ui.lastActiveRepoId = 'repo-1' - current.ui.lastActiveWorktreeId = 'ssh:target-1|repo-1::/srv/worktree' - current.ui.workspaceHostScope = 'ssh:target-1' - current.ui.showDotfilesByWorktree = { - 'ssh:target-1|repo-1::/srv/worktree': false - } - const scoped = session({ - tabsByWorktree: { - 'ssh:target-1|repo-1::/srv/worktree': [ - terminalTab('tab-1', 'ssh:target-1|repo-1::/srv/worktree') - ] - }, - tabGroups: {} - }) - - const result = collectOrcadMigrationSourceClientState( - current, - source, - catalog, - 'environment-1', - scoped - ) - expect(result.blockedCounts).toEqual({ - 'mobile-tab-selection': 0, - 'ui-routing': 0, - 'saved-port-forward': 0 - }) - expect(result.payload?.mobileClientTabSelectionsByDeviceId).toMatchObject({ - phone: { 'repo-1::/srv/worktree': { activeTabId: 'tab-1' } } - }) - expect(result.payload?.uiRouting).toMatchObject({ - lastActiveRepoId: 'repo-1', - lastActiveWorktreeId: 'repo-1::/srv/worktree', - workspaceHostScope: 'local', - showDotfilesByWorktree: { 'repo-1::/srv/worktree': false } - }) - }) - - it('blocks a mobile selection that points at a group outside the migrated owner', () => { - const current = state() - const sourceOwner = 'ssh:target-1|repo-1::/srv/worktree' - const unrelatedOwner = 'ssh:target-1|repo-2::/srv/other' - current.mobileClientTabSelectionsByDeviceId = { - phone: { - [sourceOwner]: { - activeTabId: null, - activeGroupId: 'group-unrelated', - activeTabIdByGroupId: {} - } - } - } - const scoped = session({ - tabsByWorktree: {}, - tabGroups: { - [unrelatedOwner]: [ - { - id: 'group-unrelated', - worktreeId: unrelatedOwner, - activeTabId: null, - tabOrder: [] - } - ] - } - }) - - const result = collectOrcadMigrationSourceClientState( - current, - source, - catalog, - 'environment-1', - scoped - ) - - expect(result.payload?.mobileClientTabSelectionsByDeviceId).toBeUndefined() - expect(result.blockedCounts['mobile-tab-selection']).toBe(1) - }) - - it('blocks a per-group tab selection whose group is outside the migrated owner', () => { - const current = state() - const sourceOwner = 'ssh:target-1|repo-1::/srv/worktree' - const unrelatedOwner = 'ssh:target-1|repo-2::/srv/other' - current.mobileClientTabSelectionsByDeviceId = { - phone: { - [sourceOwner]: { - activeTabId: null, - activeGroupId: null, - activeTabIdByGroupId: { 'group-unrelated': 'tab-source' } - } - } - } - const scoped = session({ - tabsByWorktree: { - [sourceOwner]: [terminalTab('tab-source', sourceOwner)] - }, - tabGroups: { - [unrelatedOwner]: [ - { - id: 'group-unrelated', - worktreeId: unrelatedOwner, - activeTabId: 'tab-source', - tabOrder: ['tab-source'] - } - ] - } - }) - - const result = collectOrcadMigrationSourceClientState( - current, - source, - catalog, - 'environment-1', - scoped - ) - - expect(result.payload?.mobileClientTabSelectionsByDeviceId).toBeUndefined() - expect(result.blockedCounts['mobile-tab-selection']).toBe(1) - }) - - it('captures saved forwards and reports duplicate local ports', () => { - const current = state() - current.sshTargets[0].portForwards = [ - { localPort: 9000, remoteHost: '127.0.0.1', remotePort: 6768 }, - { localPort: 9000, remoteHost: '127.0.0.1', remotePort: 6769 } - ] - const result = collectOrcadMigrationSourceClientState( - current, - source, - catalog, - undefined, - undefined - ) - expect(result.payload?.savedPortForwards).toHaveLength(2) - expect(result.blockedCounts['saved-port-forward']).toBe(1) - }) - - it('captures only close intents for transferred client-hosted pages', () => { - const current = state() - const worktreeId = 'ssh:target-1|repo-1::/srv/worktree' - current.workspaceSession = session({ - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - browserTabsByWorktree: { - [worktreeId]: [browserWorkspace('browser-1', worktreeId)] - }, - clientHostedBrowserPagesByWorktree: { - [worktreeId]: [hostedPage('page-1', 'browser-1')] - }, - clientHostedBrowserCloseIntentsByEnvironment: { - 'old-environment': [ - { browserPageId: 'page-1', worktreeId, closedAt: 42 }, - { browserPageId: 'unrelated-page', worktreeId, closedAt: 43 } - ], - 'environment-1': [{ browserPageId: 'destination-page', worktreeId, closedAt: 44 }] - } - }) - const result = collectOrcadMigrationSourceClientState( - current, - source, - catalog, - 'environment-1', - current.workspaceSession - ) - - expect(result.blockedCount).toBe(1) - expect(result.payload?.clientHostedBrowserCloseIntents).toEqual([ - { - sourceEnvironmentId: 'old-environment', - browserPageId: 'page-1', - worktreeId: 'repo-1::/srv/worktree', - closedAt: 42 - } - ]) - }) -}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts deleted file mode 100644 index e01be755087e..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { hostStableKey } from '../../../shared/automation-owner-key' -import { parsePersistedAutomationHostFilter } from '../../../shared/automation-host-filter' -import type { - OrcadMigrationClientStatePayload, - OrcadMigrationUiRoutingState -} from '../../../shared/orcad-migration-client-state' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationManifestSource -} from '../../../shared/orcad-migration-manifest' -import type { - PersistedMobileClientTabSelection, - PersistedState -} from '../../../shared/persisted-state-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { orcadMigrationPaneBelongsToTabs } from './orcad-source-session-dependencies' -import { collectCloseIntents } from './orcad-source-client-browser-intents' -import { - createOrcadMigrationSourceScope, - orcadMigrationOwnerMatchesScope, - unqualifyOrcadMigrationOwnerKey -} from './orcad-source-scope' - -export type OrcadMigrationSourceClientStateInspection = { - payload: OrcadMigrationClientStatePayload | undefined - /** Invalid or unmatched closes are folded into the existing workspace-session blocker. */ - blockedCount: number - blockedCounts: { - 'mobile-tab-selection': number - 'ui-routing': number - 'saved-port-forward': number - } -} - -export function collectOrcadMigrationSourceClientState( - state: PersistedState, - source: OrcadMigrationManifestSource, - catalog: OrcadMigrationCatalogPayload, - destinationEnvironmentId: string | undefined, - eligibleSession: WorkspaceSessionState | undefined -): OrcadMigrationSourceClientStateInspection { - const scope = createOrcadMigrationSourceScope({ source, catalog }) - const blockedCounts = { - 'mobile-tab-selection': 0, - 'ui-routing': 0, - 'saved-port-forward': 0 - } - let blockedCount = 0 - const mobile = collectMobileSelections(state, scope, eligibleSession, blockedCounts) - const uiRouting = collectUiRouting( - state, - scope, - destinationEnvironmentId, - eligibleSession, - blockedCounts - ) - const target = state.sshTargets.find((entry) => entry.id === scope.targetId) - const savedPortForwards = target?.portForwards ? structuredClone(target.portForwards) : undefined - if (savedPortForwards) { - const ports = new Set() - for (const forward of savedPortForwards) { - if (ports.has(forward.localPort)) { - blockedCounts['saved-port-forward'] += 1 - } - ports.add(forward.localPort) - } - } - const closeIntents = collectCloseIntents( - state, - scope, - destinationEnvironmentId, - eligibleSession, - () => { - blockedCount += 1 - } - ) - const clientState: OrcadMigrationClientStatePayload = { - ...(mobile && Object.keys(mobile).length > 0 - ? { mobileClientTabSelectionsByDeviceId: mobile } - : {}), - ...(uiRouting && Object.keys(uiRouting).length > 0 ? { uiRouting } : {}), - ...(savedPortForwards && savedPortForwards.length > 0 ? { savedPortForwards } : {}), - ...(closeIntents && closeIntents.length > 0 - ? { clientHostedBrowserCloseIntents: closeIntents } - : {}) - } - return { - payload: Object.keys(clientState).length > 0 ? clientState : undefined, - blockedCount, - blockedCounts - } -} - -function collectMobileSelections( - state: PersistedState, - scope: ReturnType, - session: WorkspaceSessionState | undefined, - blockedCounts: OrcadMigrationSourceClientStateInspection['blockedCounts'] -): - | NonNullable - | undefined { - const eligible = session ? collectEligibleSessionIdentity(session, scope) : null - const result: NonNullable< - OrcadMigrationClientStatePayload['mobileClientTabSelectionsByDeviceId'] - > = {} - const destinationKeys = new Set() - for (const [deviceId, selections] of Object.entries( - state.mobileClientTabSelectionsByDeviceId ?? {} - )) { - const projected: Record = {} - for (const [ownerKey, selection] of Object.entries(selections)) { - if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { - continue - } - const destinationKey = unqualifyOrcadMigrationOwnerKey(ownerKey) - if (destinationKeys.has(`${deviceId}\0${destinationKey}`)) { - blockedCounts['mobile-tab-selection'] += 1 - continue - } - if (!mobileSelectionIsRepresentable(selection, eligible)) { - blockedCounts['mobile-tab-selection'] += 1 - continue - } - destinationKeys.add(`${deviceId}\0${destinationKey}`) - projected[destinationKey] = structuredClone(selection) - } - if (Object.keys(projected).length > 0) { - result[deviceId] = projected - } - } - return Object.keys(result).length > 0 ? result : undefined -} - -function collectUiRouting( - state: PersistedState, - scope: ReturnType, - destinationEnvironmentId: string | undefined, - session: WorkspaceSessionState | undefined, - blockedCounts: OrcadMigrationSourceClientStateInspection['blockedCounts'] -): OrcadMigrationUiRoutingState | undefined { - const ui = state.ui - const result: OrcadMigrationUiRoutingState = {} - if (ui.lastActiveRepoId && scope.repoIds.has(ui.lastActiveRepoId)) { - result.lastActiveRepoId = ui.lastActiveRepoId - } - if (ui.lastActiveWorktreeId && orcadMigrationOwnerMatchesScope(ui.lastActiveWorktreeId, scope)) { - result.lastActiveWorktreeId = unqualifyOrcadMigrationOwnerKey(ui.lastActiveWorktreeId) - } - const filterRepoIds = ui.filterRepoIds.filter((repoId) => scope.repoIds.has(repoId)) - if (filterRepoIds.length > 0) { - result.filterRepoIds = filterRepoIds - } - const dotfiles = Object.entries(ui.showDotfilesByWorktree ?? {}) - .filter(([ownerKey]) => orcadMigrationOwnerMatchesScope(ownerKey, scope)) - .map(([ownerKey, enabled]) => [unqualifyOrcadMigrationOwnerKey(ownerKey), enabled] as const) - if (dotfiles.length > 0) { - result.showDotfilesByWorktree = Object.fromEntries(dotfiles) - } - const dismissed = (ui.setupScriptPromptDismissedRepoIds ?? []).filter((repoId) => - scope.repoIds.has(repoId) - ) - if (dismissed.length > 0) { - result.setupScriptPromptDismissedRepoIds = dismissed - } - const manualOrder = (ui.manualRepoOrder ?? []) - .filter((entry) => entry.hostId === scope.hostId && scope.repoIds.has(entry.repoId)) - .map((entry) => ({ hostId: 'local' as const, repoId: entry.repoId })) - if (manualOrder.length > 0) { - result.manualRepoOrder = manualOrder - } - if (ui.workspaceHostScope === scope.hostId) { - result.workspaceHostScope = 'local' - } - const visibleHosts = (ui.visibleWorkspaceHostIds ?? []).filter( - (hostId) => hostId === scope.hostId - ) - if (visibleHosts.length > 0) { - result.visibleWorkspaceHostIds = ['local'] - } - const hostOrder = (ui.workspaceHostOrder ?? []).filter((hostId) => hostId === scope.hostId) - if (hostOrder.length > 0) { - result.workspaceHostOrder = ['local'] - } - const filter = parsePersistedAutomationHostFilter(ui.automationHostFilter) - if ( - filter.kind === 'host' && - hostStableKey(filter.host) === `host:desktop:ssh:${encodeURIComponent(scope.targetId)}` - ) { - result.automationHostFilter = destinationEnvironmentId - ? { - kind: 'host', - hostKey: hostStableKey({ - authority: { kind: 'runtime', environmentId: destinationEnvironmentId }, - selector: { kind: 'self' } - }) - } - : { kind: 'host', hostKey: 'host:desktop:self' } - } - const eligible = session ? collectEligibleSessionIdentity(session, scope) : null - const acknowledgements = Object.entries(ui.acknowledgedAgentsByPaneKey ?? {}).filter( - ([paneKey]) => { - const allowed = eligible ? orcadMigrationPaneBelongsToTabs(paneKey, eligible.tabIds) : false - if (!allowed) { - blockedCounts['ui-routing'] += 1 - } - return allowed - } - ) - if (acknowledgements.length > 0) { - result.acknowledgedAgentsByPaneKey = Object.fromEntries(acknowledgements) - } - return Object.keys(result).length > 0 ? result : undefined -} - -type EligibleSessionIdentity = { tabIds: ReadonlySet; groupIds: ReadonlySet } - -function collectEligibleSessionIdentity( - session: WorkspaceSessionState, - scope: ReturnType -): EligibleSessionIdentity { - const tabIds = new Set() - const groupIds = new Set() - for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { - if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { - continue - } - tabs.forEach((tab) => tabIds.add(tab.id)) - } - for (const [ownerKey, groups] of Object.entries(session.tabGroups ?? {})) { - if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { - continue - } - groups.forEach((group) => groupIds.add(group.id)) - } - return { tabIds, groupIds } -} - -function mobileSelectionIsRepresentable( - selection: PersistedMobileClientTabSelection, - eligible: EligibleSessionIdentity | null -): boolean { - if (!eligible) { - return ( - selection.activeTabId === null && - selection.activeGroupId === null && - Object.keys(selection.activeTabIdByGroupId).length === 0 - ) - } - if (selection.activeTabId !== null && !eligible.tabIds.has(selection.activeTabId)) { - return false - } - if (selection.activeGroupId !== null && !eligible.groupIds.has(selection.activeGroupId)) { - return false - } - return Object.entries(selection.activeTabIdByGroupId).every( - ([groupId, tabId]) => eligible.groupIds.has(groupId) && eligible.tabIds.has(tabId) - ) -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts deleted file mode 100644 index 49cb049057f4..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultPersistedState } from '../../../shared/constants' -import type { Automation, AutomationRun } from '../../../shared/automations-types' -import type { FolderWorkspace } from '../../../shared/folder-workspace-types' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - type OrcadMigrationManifest -} from '../../../shared/orcad-migration-manifest' -import type { Repo } from '../../../shared/repo-types' -import type { SshTarget } from '../../../shared/ssh-types' -import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' -import { getRemoteRetirementNamespaceKey } from '../../worktree-name-retirement' -import { collectOrcadMigrationSourceDependencyCensus } from './orcad-source-dependency-census' - -const TARGET: SshTarget = { - id: 'ssh-prod', - label: 'Production', - host: 'prod.example.com', - port: 22, - username: 'deploy', - generation: 8 -} - -const REPO: Repo = { - id: 'repo-1', - path: '/srv/repo', - displayName: 'Repository', - badgeColor: '#737373', - addedAt: 1, - connectionId: TARGET.id, - executionHostId: `ssh:${TARGET.id}`, - projectGroupId: 'group-1' -} - -const FOLDER: FolderWorkspace = { - id: 'folder-1', - projectGroupId: 'group-1', - name: 'Folder', - folderPath: '/srv/folder', - connectionId: TARGET.id, - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 -} - -const MANIFEST: OrcadMigrationManifest = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: 'migration-1', - createdAt: '2026-08-30T12:00:00.000Z', - source: { - sshTargetId: TARGET.id, - sshTargetGeneration: TARGET.generation ?? null, - targetLabel: TARGET.label - }, - payload: { - repositories: [REPO], - projectGroups: [], - folderWorkspaces: [FOLDER] - }, - manifestSha256: 'a'.repeat(64) -} - -describe('orcad migration source dependency census', () => { - it('allows a static catalog with no unrepresented dependent state', () => { - const state = getDefaultPersistedState('/home/test') - state.sshTargets = [TARGET] - state.repos = [REPO] - state.folderWorkspaces = [FOLDER] - - expect(collectOrcadMigrationSourceDependencyCensus(state, MANIFEST)).toMatchObject({ - totalCount: 0 - }) - }) - - it('counts every currently unrepresented ownership surface before remote mutation', () => { - const state = getDefaultPersistedState('/home/test') - const worktreeId = `${REPO.id}::/srv/worktree` - const folderKey = folderWorkspaceKey(FOLDER.id) - state.sshTargets = [ - { - ...TARGET, - portForwards: [{ localPort: 3000, remoteHost: '127.0.0.1', remotePort: 3000 }] - } - ] - state.repos = [REPO] - state.folderWorkspaces = [FOLDER] - state.sshRemotePtyLeases = [ - { - targetId: TARGET.id, - ptyId: 'pty-1', - worktreeId, - state: 'detached', - createdAt: 1, - updatedAt: 1 - } - ] - state.sshPtyConsumerRecoveries = [ - { - targetId: TARGET.id, - clientInstanceId: 'client-1', - serverBuildId: 'build-1', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'lease-1' - } - ] - state.workspaceSessionsByHostId = { - [`ssh:${TARGET.id}`]: { - ...state.workspaceSession, - tabsByWorktree: { - [worktreeId]: [ - { - id: 'tab-1', - ptyId: 'pty-1', - worktreeId, - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } - } - } - state.worktreeMeta[worktreeId] = { - displayName: 'Worktree', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - hostId: `ssh:${TARGET.id}` - } - state.worktreeLineageById[worktreeId] = { - worktreeId, - worktreeInstanceId: 'instance-1', - parentWorktreeId: REPO.id, - parentWorktreeInstanceId: 'instance-parent', - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt: 1 - } - state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] = { - childWorkspaceKey: worktreeWorkspaceKey(worktreeId), - parentWorkspaceKey: folderKey, - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt: 1 - } - state.sparsePresetsByRepo[REPO.id] = [ - { - id: 'preset-1', - repoId: REPO.id, - name: 'UI', - directories: ['src/renderer'], - createdAt: 1, - updatedAt: 1 - } - ] - state.retiredWorktreeNamesByRepo![REPO.id] = { exhaustedTiers: 0, names: ['nautilus'] } - const namespaceKey = getRemoteRetirementNamespaceKey(REPO, state.settings, (targetId) => - state.sshTargets.find((target) => target.id === targetId) - ) - expect(namespaceKey).not.toBeNull() - if (!namespaceKey || !state.retiredWorktreeNamesByNamespace) { - throw new Error('expected source retirement namespace') - } - state.retiredWorktreeNamesByNamespace[namespaceKey] = { - exhaustedTiers: 0, - names: ['seahorse'] - } - state.automations = [automation()] - state.automationRuns = [automationRun()] - state.mobileClientTabSelectionsByDeviceId = { - 'device-1': { - [folderKey]: { activeTabId: null, activeGroupId: null, activeTabIdByGroupId: {} } - } - } - state.ui.lastActiveRepoId = REPO.id - - expect(collectOrcadMigrationSourceDependencyCensus(state, MANIFEST)).toEqual({ - totalCount: 14, - counts: { - automation: 1, - 'automation-run': 1, - 'mobile-tab-selection': 1, - 'retired-worktree-name': 2, - 'saved-port-forward': 1, - 'sparse-preset': 1, - 'terminal-lease': 1, - 'terminal-recovery': 1, - 'ui-routing': 1, - 'workspace-lineage': 1, - 'workspace-session': 1, - 'worktree-lineage': 1, - 'worktree-metadata': 1 - } - }) - }) - - it('keeps another SSH target and its host partition outside the source fence', () => { - const state = getDefaultPersistedState('/home/test') - state.sshTargets = [TARGET, { ...TARGET, id: 'ssh-other', generation: 9 }] - state.workspaceSessionsByHostId = { - 'ssh:ssh-other': { - ...state.workspaceSession, - tabsByWorktree: { - 'other-repo::/srv/worktree': [ - { - id: 'tab-other', - ptyId: 'pty-other', - worktreeId: 'other-repo::/srv/worktree', - title: 'Other', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } - } - } - - expect(collectOrcadMigrationSourceDependencyCensus(state, MANIFEST).totalCount).toBe(0) - }) - - it('does not strand a static migration on an owner-recovery tombstone after all leases are final', () => { - const state = getDefaultPersistedState('/home/test') - state.sshTargets = [TARGET] - state.repos = [REPO] - state.folderWorkspaces = [FOLDER] - state.sshRemotePtyLeases = [ - { - targetId: TARGET.id, - ptyId: 'pty-finished', - state: 'terminated', - createdAt: 1, - updatedAt: 2 - } - ] - state.sshPtyConsumerRecoveries = [ - { - targetId: TARGET.id, - clientInstanceId: 'client-1', - serverBuildId: 'build-1', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'lease-1' - } - ] - - expect(collectOrcadMigrationSourceDependencyCensus(state, MANIFEST)).toMatchObject({ - totalCount: 0, - counts: { 'terminal-recovery': 0 } - }) - }) -}) - -function automation(): Automation { - return { - id: 'automation-1', - name: 'Remote task', - prompt: 'Run checks', - precheck: null, - agentId: 'codex', - projectId: REPO.id, - executionTargetType: 'ssh', - executionTargetId: TARGET.id, - executionTargetGeneration: TARGET.generation, - schedulerOwner: 'ssh_bridge', - workspaceMode: 'existing', - workspaceId: null, - baseBranch: null, - reuseSession: false, - timezone: 'UTC', - rrule: 'FREQ=DAILY', - dtstart: 1, - enabled: true, - nextRunAt: 2, - missedRunPolicy: 'run_once_within_grace', - missedRunGraceMinutes: 60, - createdAt: 1, - updatedAt: 1 - } -} - -function automationRun(): AutomationRun { - return { - id: 'run-1', - automationId: 'automation-1', - title: 'Remote task #1', - scheduledFor: 1, - status: 'completed', - trigger: 'scheduled', - workspaceId: null, - sessionKind: 'terminal', - chatSessionId: null, - terminalSessionId: null, - terminalPaneKey: null, - terminalPtyId: null, - outputSnapshot: null, - precheckResult: null, - usage: null, - error: null, - startedAt: 1, - dispatchedAt: 1, - createdAt: 1 - } -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts deleted file mode 100644 index b6f317f44403..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { parsePersistedAutomationHostFilter } from '../../../shared/automation-host-filter' -import { getAutomationRunRepoId } from '../../../shared/automation-run-identity' -import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' -import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' -import { inspectOrcadSourceWorktreeMetadata } from './orcad-source-worktree-metadata' -import { - ORCAD_MIGRATION_DEPENDENCY_KINDS, - type OrcadMigrationDependencyKind -} from '../../../shared/orcad-migration-preflight' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { TerminalScrollbackSnapshotStorage } from '../../terminal-scrollback-snapshots' -import { isEmptyRetiredNameRegistry } from '../../../shared/worktree/retired-name-registry' -import { extractRetiredNameRegistriesByNamespace } from '../../orca-profiles/profile-project-retired-name-transfer' -import { - inspectOrcadMigrationSourceSessions, - orcadMigrationPaneBelongsToTabs, - type OrcadMigrationSourceSessionInspection -} from './orcad-source-session-dependencies' -import { - collectOrcadMigrationSourceDormantState, - emptyDormantPayload, - ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS -} from './orcad-source-dormant-state' -import { - createOrcadMigrationSourceScope, - orcadMigrationOwnerMatchesScope, - type OrcadMigrationSourceScope -} from './orcad-source-scope' - -export type OrcadMigrationSourceDependencyCensus = { - totalCount: number - counts: Record -} - -export function collectOrcadMigrationSourceDependencyCensus( - state: PersistedState, - manifest: OrcadMigrationManifest, - storage?: TerminalScrollbackSnapshotStorage -): OrcadMigrationSourceDependencyCensus { - return collectDependencyCensus(state, manifest, false, storage) -} - -export function collectOrcadMigrationUntransferredDependencyCensus( - state: PersistedState, - manifest: OrcadMigrationManifest, - storage?: TerminalScrollbackSnapshotStorage -): OrcadMigrationSourceDependencyCensus { - return collectDependencyCensus(state, manifest, true, storage) -} - -function collectDependencyCensus( - state: PersistedState, - manifest: OrcadMigrationManifest, - ignoreTransferredDormantState: boolean, - storage?: TerminalScrollbackSnapshotStorage -): OrcadMigrationSourceDependencyCensus { - const scope = createOrcadMigrationSourceScope({ - source: manifest.source, - catalog: manifest.payload - }) - const counts: Record = { - automation: 0, - 'automation-run': 0, - 'mobile-tab-selection': 0, - 'retired-worktree-name': 0, - 'saved-port-forward': 0, - 'sparse-preset': 0, - 'terminal-lease': 0, - 'terminal-recovery': 0, - 'ui-routing': 0, - 'workspace-lineage': 0, - 'workspace-session': 0, - 'worktree-lineage': 0, - 'worktree-metadata': 0 - } - const target = state.sshTargets.find((entry) => entry.id === scope.targetId) - counts['saved-port-forward'] = target?.portForwards?.length ?? 0 - counts['terminal-lease'] = state.sshRemotePtyLeases.filter( - (lease) => lease.targetId === scope.targetId && lease.state !== 'terminated' - ).length - - const sessions = inspectOrcadMigrationSourceSessions(state, { - hostId: scope.hostId, - ownerMatches: (ownerKey) => orcadMigrationOwnerMatchesScope(ownerKey, scope), - targetId: scope.targetId - }) - counts['workspace-session'] = sessions.dependencyCount - counts['terminal-recovery'] = countTerminalRecoveryState(state, scope, sessions) - const metadata = inspectOrcadSourceWorktreeMetadata(state, scope) - counts['worktree-metadata'] = metadata.rows.length + metadata.blockedCount - counts['worktree-lineage'] = Object.entries(state.worktreeLineageById).filter( - ([ownerKey, lineage]) => - orcadMigrationOwnerMatchesScope(ownerKey, scope) || - orcadMigrationOwnerMatchesScope(lineage.worktreeId, scope) || - orcadMigrationOwnerMatchesScope(lineage.parentWorktreeId, scope) - ).length - counts['workspace-lineage'] = Object.entries(state.workspaceLineageByChildKey).filter( - ([ownerKey, lineage]) => - orcadMigrationOwnerMatchesScope(ownerKey, scope) || - orcadMigrationOwnerMatchesScope(lineage.childWorkspaceKey, scope) || - orcadMigrationOwnerMatchesScope(lineage.parentWorkspaceKey, scope) - ).length - counts['sparse-preset'] = [...scope.repoIds].reduce( - (total, repoId) => total + (state.sparsePresetsByRepo[repoId]?.length ?? 0), - 0 - ) - counts['retired-worktree-name'] = countRetiredWorktreeNameState(state, manifest) - - const blockedAutomationIds = new Set( - state.automations - .filter((automation) => automationMatchesScope(automation, scope)) - .map(({ id }) => id) - ) - counts.automation = blockedAutomationIds.size - counts['automation-run'] = state.automationRuns.filter( - (run) => - blockedAutomationIds.has(run.automationId) || - orcadMigrationOwnerMatchesScope(run.workspaceId, scope) || - executionContextMatchesScope(run.runContext, scope) || - executionContextMatchesScope(run.sourceContext, scope) - ).length - counts['mobile-tab-selection'] = Object.values( - state.mobileClientTabSelectionsByDeviceId ?? {} - ).reduce( - (total, selections) => - total + - Object.keys(selections).filter((ownerKey) => orcadMigrationOwnerMatchesScope(ownerKey, scope)) - .length, - 0 - ) - counts['ui-routing'] = countUiRoutingState(state, scope, sessions) - const dormant = collectOrcadMigrationSourceDormantState( - state, - manifest.source, - manifest.payload, - storage, - manifest.destinationEnvironmentId - ) - const dormantMatches = - ignoreTransferredDormantState || - serializeOrcadMigrationValue(dormant.payload) === - serializeOrcadMigrationValue(manifest.payload.dormantState ?? emptyDormantPayload()) - if (dormantMatches) { - for (const kind of ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS) { - counts[kind] = dormant.blockedCounts[kind] - } - } - return { - counts, - totalCount: ORCAD_MIGRATION_DEPENDENCY_KINDS.reduce((total, kind) => total + counts[kind], 0) - } -} - -function countTerminalRecoveryState( - state: PersistedState, - scope: OrcadMigrationSourceScope, - sessions: OrcadMigrationSourceSessionInspection -): number { - // Expired routes may still own remote work; only terminated leases resolve recovery authority. - const hasActiveLease = state.sshRemotePtyLeases.some( - (lease) => lease.targetId === scope.targetId && lease.state !== 'terminated' - ) - let count = (state.sshPtyConsumerRecoveries ?? []).filter( - (recovery) => recovery.targetId === scope.targetId && hasActiveLease - ).length - count += state.migrationUnsupportedPtyEntries.filter( - (entry) => - orcadMigrationOwnerMatchesScope(entry.worktreeId, scope) || - (entry.tabId ? sessions.tabIds.has(entry.tabId) : false) || - sessions.ptyIds.has(entry.ptyId) || - (entry.paneKey ? orcadMigrationPaneBelongsToTabs(entry.paneKey, sessions.tabIds) : false) - ).length - count += state.legacyPaneKeyAliasEntries.filter( - (entry) => - orcadMigrationPaneBelongsToTabs(entry.legacyPaneKey, sessions.tabIds) || - orcadMigrationPaneBelongsToTabs(entry.stablePaneKey, sessions.tabIds) - ).length - return count -} - -function countRetiredWorktreeNameState( - state: PersistedState, - manifest: OrcadMigrationManifest -): number { - let count = manifest.payload.repositories.filter((repo) => { - const registry = state.retiredWorktreeNamesByRepo?.[repo.id] - return registry !== undefined && !isEmptyRetiredNameRegistry(registry) - }).length - const namespaceKeys = new Set() - for (const repo of manifest.payload.repositories) { - Object.keys(extractRetiredNameRegistriesByNamespace(state, repo)).forEach((key) => - namespaceKeys.add(key) - ) - } - count += namespaceKeys.size - return count -} - -function automationMatchesScope( - automation: PersistedState['automations'][number], - scope: OrcadMigrationSourceScope -): boolean { - return ( - (automation.executionTargetType === 'ssh' && automation.executionTargetId === scope.targetId) || - (scope.targetGeneration !== null && - automation.executionTargetGeneration === scope.targetGeneration) || - scope.repoIds.has(getAutomationRunRepoId(automation)) || - orcadMigrationOwnerMatchesScope(automation.workspaceId, scope) || - executionContextMatchesScope(automation.runContext, scope) || - executionContextMatchesScope(automation.sourceContext, scope) - ) -} - -function executionContextMatchesScope( - context: { hostId: string; repoId?: string | null } | null | undefined, - scope: OrcadMigrationSourceScope -): boolean { - return ( - context?.hostId === scope.hostId || - (typeof context?.repoId === 'string' && scope.repoIds.has(context.repoId)) - ) -} - -function countUiRoutingState( - state: PersistedState, - scope: OrcadMigrationSourceScope, - sessions: OrcadMigrationSourceSessionInspection -): number { - const ui = state.ui - let count = ui.lastActiveRepoId && scope.repoIds.has(ui.lastActiveRepoId) ? 1 : 0 - count += orcadMigrationOwnerMatchesScope(ui.lastActiveWorktreeId, scope) ? 1 : 0 - count += ui.filterRepoIds.filter((repoId) => scope.repoIds.has(repoId)).length - count += Object.keys(ui.showDotfilesByWorktree ?? {}).filter((ownerKey) => - orcadMigrationOwnerMatchesScope(ownerKey, scope) - ).length - count += (ui.setupScriptPromptDismissedRepoIds ?? []).filter((repoId) => - scope.repoIds.has(repoId) - ).length - count += (ui.manualRepoOrder ?? []).filter( - (entry) => entry.hostId === scope.hostId || scope.repoIds.has(entry.repoId) - ).length - count += ui.workspaceHostScope === scope.hostId ? 1 : 0 - count += (ui.visibleWorkspaceHostIds ?? []).filter((hostId) => hostId === scope.hostId).length - count += (ui.workspaceHostOrder ?? []).filter((hostId) => hostId === scope.hostId).length - const automationFilter = parsePersistedAutomationHostFilter(ui.automationHostFilter) - count += - automationFilter.kind === 'host' && - automationFilter.host.authority.kind === 'desktop' && - automationFilter.host.selector.kind === 'ssh' && - automationFilter.host.selector.targetId === scope.targetId - ? 1 - : 0 - count += Object.keys(ui.acknowledgedAgentsByPaneKey ?? {}).filter((paneKey) => - orcadMigrationPaneBelongsToTabs(paneKey, sessions.tabIds) - ).length - return count -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts deleted file mode 100644 index 49cd5ce631ab..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts +++ /dev/null @@ -1,273 +0,0 @@ -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationDormantStatePayload, - OrcadMigrationManifest, - OrcadMigrationManifestSource -} from '../../../shared/orcad-migration-manifest' -import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' -import type { OrcadMigrationDependencyKind } from '../../../shared/orcad-migration-preflight' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { TerminalScrollbackSnapshotStorage } from '../../terminal-scrollback-snapshots' -import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' -import { isEmptyRetiredNameRegistry } from '../../../shared/worktree/retired-name-registry' -import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' -import { toOrcadDestinationRepository } from './orcad-destination-catalog-projection' -import { collectOrcadMigrationRetiredWorktreeNamespaces } from './orcad-source-retired-worktree-names' -import { collectOrcadMigrationSourceAutomationState } from './orcad-source-automation-state' -import { collectOrcadMigrationSourceWorkspaceSession } from './orcad-source-workspace-session' -import { - createOrcadMigrationSourceScope, - orcadMigrationOwnerMatchesScope, - unqualifyOrcadMigrationOwnerKey -} from './orcad-source-scope' -import { collectOrcadMigrationSourceClientState } from './orcad-source-client-state' -import { inspectOrcadSourceWorktreeMetadata } from './orcad-source-worktree-metadata' - -export const ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS = [ - 'worktree-metadata', - 'worktree-lineage', - 'workspace-lineage', - 'workspace-session', - 'automation', - 'automation-run', - 'sparse-preset', - 'retired-worktree-name', - 'mobile-tab-selection', - 'ui-routing', - 'saved-port-forward' -] as const satisfies readonly OrcadMigrationDependencyKind[] - -type TransferredDormantKind = (typeof ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS)[number] - -export type OrcadMigrationSourceDormantInspection = { - payload: OrcadMigrationDormantStatePayload - blockedCounts: Record -} - -export function collectOrcadMigrationSourceDormantState( - state: PersistedState, - source: OrcadMigrationManifestSource, - catalog: OrcadMigrationCatalogPayload, - storage?: TerminalScrollbackSnapshotStorage, - destinationEnvironmentId?: string -): OrcadMigrationSourceDormantInspection { - const scope = createOrcadMigrationSourceScope({ source, catalog }) - const blockedCounts = emptyBlockedCounts() - const destinationRepos = catalog.repositories.map(toOrcadDestinationRepository) - const setupByRepoId = new Map( - projectHostSetupProjectionFromRepos(destinationRepos).setups.flatMap((setup) => - setup.repoId ? [[setup.repoId, setup] as const] : [] - ) - ) - const metadata = inspectOrcadSourceWorktreeMetadata(state, scope) - blockedCounts['worktree-metadata'] = metadata.blockedCount - const worktreeMeta = uniqueDestinationRows( - metadata.rows.flatMap(({ sourceKey, meta }) => { - const worktreeId = unqualifyOrcadMigrationOwnerKey(sourceKey) - const setup = setupByRepoId.get(getRepoIdFromWorktreeId(worktreeId)) - return [ - { - sourceKey, - worktreeId, - meta: { - ...structuredClone(meta), - ...(setup ? { projectId: setup.projectId, projectHostSetupId: setup.id } : {}), - hostId: 'local' as const - } - } - ] - }), - (entry) => entry.worktreeId, - () => (blockedCounts['worktree-metadata'] += 1) - ) - const worktreeLineage = uniqueDestinationRows( - Object.entries(state.worktreeLineageById).flatMap(([sourceKey, lineage]) => { - const touches = [sourceKey, lineage.worktreeId, lineage.parentWorktreeId].some((value) => - orcadMigrationOwnerMatchesScope(value, scope) - ) - if (!touches) { - return [] - } - const worktreeId = unqualifyOrcadMigrationOwnerKey(sourceKey) - if ( - worktreeId !== lineage.worktreeId || - !orcadMigrationOwnerMatchesScope(lineage.worktreeId, scope) || - !orcadMigrationOwnerMatchesScope(lineage.parentWorktreeId, scope) - ) { - blockedCounts['worktree-lineage'] += 1 - return [] - } - return [{ sourceKey, worktreeId, lineage: structuredClone(lineage) }] - }), - (entry) => entry.worktreeId, - () => (blockedCounts['worktree-lineage'] += 1) - ) - const workspaceLineage = uniqueDestinationRows( - Object.entries(state.workspaceLineageByChildKey).flatMap(([sourceKey, lineage]) => { - const touches = [sourceKey, lineage.childWorkspaceKey, lineage.parentWorkspaceKey].some( - (value) => orcadMigrationOwnerMatchesScope(value, scope) - ) - if (!touches) { - return [] - } - const childWorkspaceKey = unqualifyOrcadMigrationOwnerKey(sourceKey) - if ( - childWorkspaceKey !== lineage.childWorkspaceKey || - !orcadMigrationOwnerMatchesScope(lineage.childWorkspaceKey, scope) || - !orcadMigrationOwnerMatchesScope(lineage.parentWorkspaceKey, scope) - ) { - blockedCounts['workspace-lineage'] += 1 - return [] - } - return [ - { - sourceKey, - childWorkspaceKey, - lineage: { - ...structuredClone(lineage), - childInstanceId: lineage.childInstanceId ?? null, - parentInstanceId: lineage.parentInstanceId ?? null - } - } - ] - }), - (entry) => entry.childWorkspaceKey, - () => (blockedCounts['workspace-lineage'] += 1) - ) - const sparsePresets = [...scope.repoIds] - .flatMap((repoId) => state.sparsePresetsByRepo[repoId] ?? []) - .map((preset) => structuredClone(preset)) - .sort((left, right) => - compareKeys(`${left.repoId}\0${left.id}`, `${right.repoId}\0${right.id}`) - ) - const retiredWorktreeNames = [...scope.repoIds] - .flatMap((repoId) => { - const registry = state.retiredWorktreeNamesByRepo?.[repoId] - return registry && !isEmptyRetiredNameRegistry(registry) - ? [{ repoId, registry: structuredClone(registry) }] - : [] - }) - .sort((left, right) => compareKeys(left.repoId, right.repoId)) - const workspaceSession = collectOrcadMigrationSourceWorkspaceSession( - state, - source, - catalog, - storage - ) - blockedCounts['workspace-session'] = workspaceSession.blockedCount - const automationState = collectOrcadMigrationSourceAutomationState(state, source, catalog) - blockedCounts.automation = automationState.blockedAutomationCount - blockedCounts['automation-run'] = automationState.blockedRunCount - const clientState = collectOrcadMigrationSourceClientState( - state, - source, - catalog, - destinationEnvironmentId, - workspaceSession.payload - ) - blockedCounts['mobile-tab-selection'] = clientState.blockedCounts['mobile-tab-selection'] - blockedCounts['ui-routing'] = clientState.blockedCounts['ui-routing'] - blockedCounts['saved-port-forward'] = clientState.blockedCounts['saved-port-forward'] - blockedCounts['workspace-session'] += clientState.blockedCount - return { - payload: { - version: 1, - worktreeMeta: worktreeMeta.sort((left, right) => - compareKeys(left.worktreeId, right.worktreeId) - ), - worktreeLineage: worktreeLineage.sort((left, right) => - compareKeys(left.worktreeId, right.worktreeId) - ), - workspaceLineage: workspaceLineage.sort((left, right) => - compareKeys(left.childWorkspaceKey, right.childWorkspaceKey) - ), - sparsePresets, - retiredWorktreeNames, - retiredWorktreeNamespaces: collectOrcadMigrationRetiredWorktreeNamespaces(state, catalog), - ...(workspaceSession.payload ? { workspaceSession: workspaceSession.payload } : {}), - ...(workspaceSession.snapshots.length > 0 - ? { terminalScrollbackSnapshots: workspaceSession.snapshots } - : {}), - ...(automationState.automations.length > 0 - ? { automations: automationState.automations } - : {}), - ...(automationState.automationRuns.length > 0 - ? { automationRuns: automationState.automationRuns } - : {}), - ...(clientState.payload ? { clientState: clientState.payload } : {}) - }, - blockedCounts - } -} - -export function orcadMigrationDormantStateMatchesSource( - state: PersistedState, - manifest: OrcadMigrationManifest, - storage?: TerminalScrollbackSnapshotStorage -): boolean { - const current = collectOrcadMigrationSourceDormantState( - state, - manifest.source, - manifest.payload, - storage, - manifest.destinationEnvironmentId - ).payload - return ( - serializeOrcadMigrationValue(current) === - serializeOrcadMigrationValue(manifest.payload.dormantState ?? emptyDormantPayload()) - ) -} - -export function emptyDormantPayload(): OrcadMigrationDormantStatePayload { - return { - version: 1, - worktreeMeta: [], - worktreeLineage: [], - workspaceLineage: [], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [] - } -} - -function uniqueDestinationRows( - rows: T[], - key: (row: T) => string, - onDuplicate: () => void -): T[] { - const unique = new Map() - const duplicates = new Set() - for (const row of rows) { - const rowKey = key(row) - if (duplicates.has(rowKey)) { - onDuplicate() - } else if (unique.has(rowKey)) { - unique.delete(rowKey) - duplicates.add(rowKey) - onDuplicate() - } else { - unique.set(rowKey, row) - } - } - return [...unique.values()] -} - -function emptyBlockedCounts(): Record { - return { - 'worktree-metadata': 0, - 'worktree-lineage': 0, - 'workspace-lineage': 0, - 'workspace-session': 0, - automation: 0, - 'automation-run': 0, - 'sparse-preset': 0, - 'retired-worktree-name': 0, - 'mobile-tab-selection': 0, - 'ui-routing': 0, - 'saved-port-forward': 0 - } -} - -function compareKeys(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0 -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts deleted file mode 100644 index b386fec9cc36..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from '../../../shared/constants' -import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { SshTarget } from '../../../shared/ssh-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { closeTestStores, createSqliteTestStore } from '../../persistence-test-harness' -import { Store } from '../loading-store/store' -import { createOrcadMigrationManifest } from '../../ssh/orcad-migration-manifest-export' - -const TARGET: SshTarget = { - id: 'ssh-prod', - label: 'Production', - host: 'prod.example.com', - port: 22, - username: 'deploy', - generation: 3 -} -const REPO_ID = 'repo-1' -const WORKTREE_ID = `${REPO_ID}::/srv/app` - -const directories: string[] = [] -afterEach(async () => { - await closeTestStores() - for (const directory of directories.splice(0)) { - rmSync(directory, { recursive: true, force: true }) - } -}) - -function dormantSession(buffer: string): WorkspaceSessionState { - return { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - [WORKTREE_ID]: [ - { - id: 'tab-1', - ptyId: null, - worktreeId: WORKTREE_ID, - title: 'Shell', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: 'leaf-1' }, - activeLeafId: 'leaf-1', - expandedLeafId: null, - buffersByLeafId: { 'leaf-1': buffer } - } - } - } -} - -function sourceStore(): Store { - const directory = mkdtempSync(join(tmpdir(), 'orcad-source-export-')) - directories.push(directory) - const store = createSqliteTestStore(Store, { dataFile: join(directory, 'orca-data.json') }) - store.addSshTarget(TARGET) - store.addRepo({ - id: REPO_ID, - path: '/srv/app', - displayName: 'App', - badgeColor: '#737373', - addedAt: 1, - kind: 'git', - connectionId: TARGET.id - }) - store.setWorkspaceSession(dormantSession('dormant output\r\n'), toSshExecutionHostId(TARGET.id)) - return store -} - -describe('exporting a relay-hosted SSH target from the profile store', () => { - it('reads the target catalog and dormant scrollback without changing the source', () => { - const store = sourceStore() - const before = JSON.stringify({ - repos: store.getRepos(), - session: store.getWorkspaceSession(toSshExecutionHostId(TARGET.id)) - }) - - const manifest = createOrcadMigrationManifest(store, TARGET) - - expect(manifest.payload.repositories.map((repo) => repo.id)).toEqual([REPO_ID]) - const snapshots = manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] - expect(snapshots).toHaveLength(1) - const chunk = store.readOrcadMigrationSourceSnapshotChunk(manifest, snapshots[0]!.ref, 0) - expect(Buffer.from(chunk.bytesBase64, 'base64').toString('utf8')).toBe('dormant output\r\n') - expect(chunk.eof).toBe(true) - expect( - JSON.stringify({ - repos: store.getRepos(), - session: store.getWorkspaceSession(toSshExecutionHostId(TARGET.id)) - }) - ).toBe(before) - }) - - it('refuses a chunk once the dormant buffer changed after export', () => { - const store = sourceStore() - const manifest = createOrcadMigrationManifest(store, TARGET) - const ref = manifest.payload.dormantState?.terminalScrollbackSnapshots?.[0]?.ref ?? '' - store.setWorkspaceSession(dormantSession('rewritten output'), toSshExecutionHostId(TARGET.id)) - expect(() => store.readOrcadMigrationSourceSnapshotChunk(manifest, ref, 0)).toThrow( - 'orcad_migration_source_snapshot_changed' - ) - }) - - it('refuses a chunk for a manifest whose digest does not match its contents', () => { - const store = sourceStore() - const manifest = createOrcadMigrationManifest(store, TARGET) - const ref = manifest.payload.dormantState?.terminalScrollbackSnapshots?.[0]?.ref ?? '' - expect(() => - store.readOrcadMigrationSourceSnapshotChunk({ ...manifest, migrationId: 'forged' }, ref, 0) - ).toThrow('orcad_migration_manifest_digest_mismatch') - }) - - it('names what still blocks: nothing, once the dormant state is exportable', () => { - const store = sourceStore() - const manifest = createOrcadMigrationManifest(store, TARGET) - const census = store.inspectOrcadMigrationUntransferredDependencies(manifest) - expect(census.counts['workspace-session']).toBe(0) - }) -}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts deleted file mode 100644 index 8e3c118eee8e..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * The Store's read-only view of a relay-hosted SSH target, for migrating it to a managed orcad. - * - * Everything here reads the profile-state store and returns copies; nothing writes or retires - * source rows. Retiring the source after a verified import is the cutover's job (T8). - */ -import { - ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES, - type OrcadMigrationTerminalScrollbackSnapshot -} from '../../../shared/orcad-migration-scrollback' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationDormantStatePayload, - OrcadMigrationManifest, - OrcadMigrationManifestSource -} from '../../../shared/orcad-migration-manifest' -import { assertOrcadMigrationManifestDigest } from '../../orcad/orcad-migration-manifest-digest' -import type { StoreRuntimeState } from '../loading-store/store-runtime-state' -import { - collectOrcadMigrationSourceDependencyCensus, - collectOrcadMigrationUntransferredDependencyCensus, - type OrcadMigrationSourceDependencyCensus -} from './orcad-source-dependency-census' -import { collectOrcadMigrationSourceDormantState } from './orcad-source-dormant-state' -import { readOrcadMigrationSourceScrollbackChunk } from './orcad-source-scrollback-state' - -type OrcadSourceExportRuntime = Pick< - StoreRuntimeState, - 'state' | 'terminalScrollbackSnapshotStorage' | 'retainedScrollbackRefsByMigrationId' -> - -const orcadSourceExportContext = Symbol('OrcadSourceExportPersistence') - -export class OrcadSourceExportPersistence { - readonly [orcadSourceExportContext]: OrcadSourceExportRuntime - - constructor(runtime: OrcadSourceExportRuntime) { - this[orcadSourceExportContext] = runtime - } - - collectOrcadMigrationSourceDormantState( - source: OrcadMigrationManifestSource, - catalog: OrcadMigrationCatalogPayload, - destinationEnvironmentId?: string - ): OrcadMigrationDormantStatePayload { - const runtime = this[orcadSourceExportContext] - return collectOrcadMigrationSourceDormantState( - runtime.state, - source, - catalog, - runtime.terminalScrollbackSnapshotStorage, - destinationEnvironmentId - ).payload - } - - /** Every dependency on the target, transferable or not; preflight decides what blocks. */ - inspectOrcadMigrationSourceDependencies( - manifest: OrcadMigrationManifest - ): OrcadMigrationSourceDependencyCensus { - const runtime = this[orcadSourceExportContext] - return collectOrcadMigrationSourceDependencyCensus( - runtime.state, - manifest, - runtime.terminalScrollbackSnapshotStorage - ) - } - - /** What still references the target that this manifest cannot carry. */ - inspectOrcadMigrationUntransferredDependencies( - manifest: OrcadMigrationManifest - ): OrcadMigrationSourceDependencyCensus { - const runtime = this[orcadSourceExportContext] - return collectOrcadMigrationUntransferredDependencyCensus( - runtime.state, - manifest, - runtime.terminalScrollbackSnapshotStorage - ) - } - - /** Keeps the snapshot files this manifest names until released, even if their tabs close. */ - retainOrcadMigrationScrollback(manifest: OrcadMigrationManifest): void { - const refs = (manifest.payload.dormantState?.terminalScrollbackSnapshots ?? []).map( - (snapshot) => snapshot.ref - ) - this[orcadSourceExportContext].retainedScrollbackRefsByMigrationId.set( - manifest.migrationId, - new Set(refs) - ) - } - - releaseOrcadMigrationScrollback(migrationId: string): void { - this[orcadSourceExportContext].retainedScrollbackRefsByMigrationId.delete(migrationId) - } - - /** - * One bounded chunk of a scrollback snapshot the signed manifest names. The bytes are checked - * against the manifest's length and digest, so a buffer that changed since export is refused. - */ - readOrcadMigrationSourceSnapshotChunk( - manifest: OrcadMigrationManifest, - ref: string, - offset: number - ): { bytesBase64: string; totalBytes: number; eof: boolean } { - assertOrcadMigrationManifestDigest(manifest) - const descriptor: OrcadMigrationTerminalScrollbackSnapshot | undefined = - manifest.payload.dormantState?.terminalScrollbackSnapshots?.find((entry) => entry.ref === ref) - if (!descriptor) { - throw new Error('orcad_migration_source_snapshot_unknown') - } - const runtime = this[orcadSourceExportContext] - return readOrcadMigrationSourceScrollbackChunk({ - state: runtime.state, - descriptor, - offset, - length: ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES, - storage: runtime.terminalScrollbackSnapshotStorage - }) - } -} - -export function installOrcadSourceExportPersistenceContext( - target: OrcadSourceExportPersistence, - source: OrcadSourceExportPersistence -): void { - Object.defineProperty(target, orcadSourceExportContext, { - value: source[orcadSourceExportContext] - }) -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-retired-worktree-names.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-retired-worktree-names.ts deleted file mode 100644 index 897782737ad8..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-retired-worktree-names.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** Retired worktree names (the name registry, not migration retirement) the target carries. */ -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationDormantStatePayload -} from '../../../shared/orcad-migration-manifest' -import type { PersistedState } from '../../../shared/persisted-state-types' -import { - isEmptyRetiredNameRegistry, - mergeRetiredNameRegistries, - type RetiredNameRegistry -} from '../../../shared/worktree/retired-name-registry' -import { getRemoteRetirementNamespaceKey } from '../../worktree-name-retirement' -import { - swapRetirementNamespaceHost, - retirementHostIdentity, - retirementNamespaceKeysToRead -} from '../../worktree-retirement-namespace' - -const EMPTY_REGISTRY: RetiredNameRegistry = { exhaustedTiers: 0, names: [] } - -export function collectOrcadMigrationRetiredWorktreeNamespaces( - state: PersistedState, - catalog: OrcadMigrationCatalogPayload -): OrcadMigrationDormantStatePayload['retiredWorktreeNamespaces'] { - const lookup = (targetId: string) => state.sshTargets.find((target) => target.id === targetId) - const byDestination = new Map< - string, - { sourceNamespaceKeys: Set; registry: RetiredNameRegistry } - >() - for (const repo of catalog.repositories) { - const canonicalSource = getRemoteRetirementNamespaceKey(repo, state.settings, lookup) - if (!canonicalSource) { - continue - } - const sourceNamespaceKeys = retirementNamespaceKeysToRead(repo, canonicalSource, lookup).filter( - (key) => state.retiredWorktreeNamesByNamespace?.[key] !== undefined - ) - if (sourceNamespaceKeys.length === 0) { - continue - } - const registry = sourceNamespaceKeys.reduce( - (merged, key) => - mergeRetiredNameRegistries( - merged, - state.retiredWorktreeNamesByNamespace?.[key] ?? { exhaustedTiers: 0, names: [] } - ), - EMPTY_REGISTRY - ) - if (isEmptyRetiredNameRegistry(registry)) { - continue - } - const namespaceKey = swapRetirementNamespaceHost( - canonicalSource, - retirementHostIdentity(repo, lookup), - LOCAL_EXECUTION_HOST_ID - ) - if (!namespaceKey) { - continue - } - const existing = byDestination.get(namespaceKey) - byDestination.set(namespaceKey, { - sourceNamespaceKeys: new Set([ - ...(existing?.sourceNamespaceKeys ?? []), - ...sourceNamespaceKeys - ]), - registry: existing ? mergeRetiredNameRegistries(existing.registry, registry) : registry - }) - } - return [...byDestination.entries()] - .map(([namespaceKey, entry]) => ({ - namespaceKey, - sourceNamespaceKeys: [...entry.sourceNamespaceKeys].sort(compareKeys), - registry: entry.registry - })) - .sort((left, right) => compareKeys(left.namespaceKey, right.namespaceKey)) -} - -function compareKeys(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0 -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts deleted file mode 100644 index 26eade4c8b78..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - createOrcadMigrationSourceScope, - orcadMigrationOwnerMatchesScope -} from './orcad-source-scope' - -const scope = createOrcadMigrationSourceScope({ - source: { sshTargetId: 'ssh-prod', sshTargetGeneration: 1, targetLabel: 'Production' }, - catalog: { - repositories: [ - { - id: 'repo-1', - path: '/srv/app', - displayName: 'App', - badgeColor: '#737373', - addedAt: 1, - kind: 'git', - connectionId: 'ssh-prod' - } - ], - projectGroups: [], - folderWorkspaces: [ - { - id: 'folder-1', - projectGroupId: 'group-1', - name: 'Notes', - folderPath: '/srv/notes', - connectionId: 'ssh-prod', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1, - lastActivityAt: 0, - createdAt: 1, - updatedAt: 1 - } - ] - } -}) - -describe('migration source scope', () => { - it.each([ - ['a worktree of an exported repository', 'repo-1::/srv/app'], - ['an exported folder workspace', 'folder:folder-1'] - ])('owns %s', (_name, ownerKey) => { - expect(orcadMigrationOwnerMatchesScope(ownerKey, scope)).toBe(true) - }) - - it.each([ - ['another repository', 'repo-2::/srv/other'], - ['another folder workspace', 'folder:folder-2'], - ['no owner', null] - ])('does not own %s', (_name, ownerKey) => { - expect(orcadMigrationOwnerMatchesScope(ownerKey, scope)).toBe(false) - }) -}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts deleted file mode 100644 index 32157a6685d5..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationManifestSource -} from '../../../shared/orcad-migration-manifest' -import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import { - getWorktreeIdFromHostIdentity, - isWorktreeHostIdentity -} from '../../../shared/worktree/host-qualified-identity' -import { ownerKeyBelongsToRepo } from '../../orca-profiles/profile-project-worktree-identity' - -export type OrcadMigrationSourceScope = { - targetId: string - targetGeneration: number | null - hostId: ReturnType - repoIds: ReadonlySet - folderWorkspaceKeys: ReadonlySet -} - -export function createOrcadMigrationSourceScope(args: { - source: OrcadMigrationManifestSource - catalog: OrcadMigrationCatalogPayload -}): OrcadMigrationSourceScope { - return { - targetId: args.source.sshTargetId, - targetGeneration: args.source.sshTargetGeneration, - hostId: toSshExecutionHostId(args.source.sshTargetId), - repoIds: new Set(args.catalog.repositories.map((repo) => repo.id)), - folderWorkspaceKeys: new Set( - args.catalog.folderWorkspaces.map((workspace) => `folder:${workspace.id}`) - ) - } -} - -export function orcadMigrationOwnerMatchesScope( - value: string | null | undefined, - scope: OrcadMigrationSourceScope -): boolean { - if (!value) { - return false - } - const rawValue = isWorktreeHostIdentity(value) ? getWorktreeIdFromHostIdentity(value) : value - if (scope.folderWorkspaceKeys.has(rawValue)) { - return true - } - for (const repoId of scope.repoIds) { - if (ownerKeyBelongsToRepo(rawValue, repoId)) { - return true - } - } - const parsed = parseWorkspaceKey(rawValue) - return ( - parsed?.type === 'folder' && scope.folderWorkspaceKeys.has(`folder:${parsed.folderWorkspaceId}`) - ) -} - -export function unqualifyOrcadMigrationOwnerKey(value: string): string { - return isWorktreeHostIdentity(value) ? getWorktreeIdFromHostIdentity(value) : value -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts deleted file mode 100644 index 516365f8fa33..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../../shared/orcad-migration-scrollback' -import { hasDuplicateOrcadMigrationScrollbackDescriptors } from './orcad-source-scrollback-state' - -const FIRST: OrcadMigrationTerminalScrollbackSnapshot = { - tabId: 'tab-1', - leafId: 'leaf-1', - ref: `v1-${'1'.repeat(32)}`, - sha256: 'a'.repeat(64), - byteLength: 1 -} - -describe('orcad source scrollback projection', () => { - it('refuses duplicate refs or tab/leaf identities across merged fragments', () => { - expect( - hasDuplicateOrcadMigrationScrollbackDescriptors([ - FIRST, - { ...FIRST, tabId: 'tab-2', leafId: 'leaf-2' } - ]) - ).toBe(true) - expect( - hasDuplicateOrcadMigrationScrollbackDescriptors([ - FIRST, - { ...FIRST, ref: `v1-${'2'.repeat(32)}` } - ]) - ).toBe(true) - expect( - hasDuplicateOrcadMigrationScrollbackDescriptors([ - FIRST, - { - ...FIRST, - tabId: 'tab-2', - leafId: 'leaf-2', - ref: `v1-${'2'.repeat(32)}` - } - ]) - ).toBe(false) - }) -}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts deleted file mode 100644 index 668ef4ac59db..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { createHash } from 'node:crypto' -import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../../shared/orcad-migration-scrollback' -import { - MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS, - MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES -} from '../../../shared/orcad-migration-scrollback' -import type { PersistedState } from '../../../shared/persisted-state-types' -import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { - makeTerminalScrollbackSnapshotRef, - readTerminalScrollbackStoredBytesSync, - type TerminalScrollbackSnapshotStorage -} from '../../terminal-scrollback-snapshots' - -export type ProjectedOrcadMigrationScrollback = { - session: WorkspaceSessionState - snapshots: OrcadMigrationTerminalScrollbackSnapshot[] - blockedCount: number -} - -export function hasDuplicateOrcadMigrationScrollbackDescriptors( - snapshots: readonly OrcadMigrationTerminalScrollbackSnapshot[] -): boolean { - const refs = new Set() - const leaves = new Set() - for (const snapshot of snapshots) { - const leaf = `${snapshot.tabId}\0${snapshot.leafId}` - if (refs.has(snapshot.ref) || leaves.has(leaf)) { - return true - } - refs.add(snapshot.ref) - leaves.add(leaf) - } - return false -} - -export function projectOrcadMigrationSessionScrollback( - session: WorkspaceSessionState, - storage?: TerminalScrollbackSnapshotStorage -): ProjectedOrcadMigrationScrollback { - const projected = structuredClone(session) - const snapshots: OrcadMigrationTerminalScrollbackSnapshot[] = [] - let blockedCount = 0 - let totalBytes = 0 - for (const [tabId, layout] of Object.entries(projected.terminalLayoutsByTabId)) { - const sourceLayout = session.terminalLayoutsByTabId[tabId] - const refs: Record = {} - const leafIds = new Set([ - ...Object.keys(sourceLayout.buffersByLeafId ?? {}), - ...Object.keys(sourceLayout.scrollbackRefsByLeafId ?? {}) - ]) - for (const leafId of [...leafIds].sort(compareKeys)) { - const buffer = sourceLayout.buffersByLeafId?.[leafId] - const sourceRef = sourceLayout.scrollbackRefsByLeafId?.[leafId] - const ref = buffer ? makeTerminalScrollbackSnapshotRef(tabId, leafId) : sourceRef - const bytes = buffer - ? Buffer.from(buffer, 'utf8') - : sourceRef - ? readTerminalScrollbackStoredBytesSync(sourceRef, storage) - : null - if ( - !ref || - !bytes || - bytes.length === 0 || - bytes.length > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT - ) { - blockedCount += 1 - continue - } - totalBytes += bytes.length - refs[leafId] = ref - snapshots.push({ - tabId, - leafId, - ref, - sha256: createHash('sha256').update(bytes).digest('hex'), - byteLength: bytes.length - }) - } - delete layout.buffersByLeafId - if (Object.keys(refs).length > 0) { - layout.scrollbackRefsByLeafId = refs - } else { - delete layout.scrollbackRefsByLeafId - } - } - if ( - snapshots.length > MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS || - totalBytes > MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES - ) { - blockedCount += 1 - } - snapshots.sort((left, right) => - compareKeys(`${left.tabId}\0${left.leafId}`, `${right.tabId}\0${right.leafId}`) - ) - return { session: projected, snapshots, blockedCount } -} - -export function readOrcadMigrationSourceScrollbackChunk(args: { - state: PersistedState - descriptor: OrcadMigrationTerminalScrollbackSnapshot - offset: number - length: number - storage?: TerminalScrollbackSnapshotStorage -}): { bytesBase64: string; totalBytes: number; eof: boolean } { - const bytes = findSnapshotBytes(args.state, args.descriptor, args.storage) - if (!bytes) { - throw new Error('orcad_migration_source_snapshot_changed') - } - if (!Number.isSafeInteger(args.offset) || args.offset < 0 || args.offset > bytes.length) { - throw new Error('orcad_migration_source_snapshot_offset_invalid') - } - const end = Math.min(bytes.length, args.offset + args.length) - return { - bytesBase64: bytes.subarray(args.offset, end).toString('base64'), - totalBytes: bytes.length, - eof: end === bytes.length - } -} - -function findSnapshotBytes( - state: PersistedState, - descriptor: OrcadMigrationTerminalScrollbackSnapshot, - storage?: TerminalScrollbackSnapshotStorage -): Buffer | null { - for (const session of sessionPartitions(state)) { - const layout = session.terminalLayoutsByTabId[descriptor.tabId] - if (!layout) { - continue - } - const buffer = layout.buffersByLeafId?.[descriptor.leafId] - const ref = layout.scrollbackRefsByLeafId?.[descriptor.leafId] - const bytes = buffer - ? Buffer.from(buffer, 'utf8') - : ref === descriptor.ref - ? readTerminalScrollbackStoredBytesSync(ref, storage) - : null - if ( - bytes && - bytes.length === descriptor.byteLength && - createHash('sha256').update(bytes).digest('hex') === descriptor.sha256 - ) { - return bytes - } - } - return null -} - -function sessionPartitions(state: PersistedState): WorkspaceSessionState[] { - return [state.workspaceSession, ...Object.values(state.workspaceSessionsByHostId ?? {})].filter( - (session): session is WorkspaceSessionState => session !== undefined - ) -} - -function compareKeys(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0 -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts deleted file mode 100644 index c6f51cb82eda..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { ExecutionHostId } from '../../../shared/execution-host' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition' - -export type OrcadMigrationSourceSessionInspection = { - dependencyCount: number - ptyIds: Set - tabIds: Set -} - -type SessionScope = { - targetId: string - hostId: ExecutionHostId - ownerMatches: (ownerKey: string) => boolean -} - -export function inspectOrcadMigrationSourceSessions( - state: PersistedState, - scope: SessionScope -): OrcadMigrationSourceSessionInspection { - const aggregate: OrcadMigrationSourceSessionInspection = { - dependencyCount: 0, - ptyIds: new Set(), - tabIds: new Set() - } - const partitions: [string, WorkspaceSessionState][] = [ - ['local', state.workspaceSession], - ...Object.entries(state.workspaceSessionsByHostId ?? {}).flatMap( - ([hostId, session]): [string, WorkspaceSessionState][] => (session ? [[hostId, session]] : []) - ) - ] - for (const [hostId, session] of partitions) { - const inspected = inspectSession(session, scope, hostId === scope.hostId) - aggregate.dependencyCount += inspected.dependencyCount - inspected.ptyIds.forEach((value) => aggregate.ptyIds.add(value)) - inspected.tabIds.forEach((value) => aggregate.tabIds.add(value)) - } - return aggregate -} - -function inspectSession( - session: WorkspaceSessionState, - scope: SessionScope, - sourceHostPartition: boolean -): OrcadMigrationSourceSessionInspection { - const result: OrcadMigrationSourceSessionInspection = { - dependencyCount: 0, - ptyIds: new Set(), - tabIds: new Set() - } - const matchesOwner = (ownerKey: string): boolean => - Boolean(ownerKey) && (sourceHostPartition || scope.ownerMatches(ownerKey)) - for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { - if (!matchesOwner(ownerKey)) { - continue - } - result.dependencyCount += 1 - for (const tab of tabs) { - result.tabIds.add(tab.id) - if (tab.ptyId) { - result.ptyIds.add(tab.ptyId) - } - } - } - const browserWorkspaceIds = new Set() - for (const [ownerKey, workspaces] of Object.entries(session.browserTabsByWorktree ?? {})) { - if (!matchesOwner(ownerKey)) { - continue - } - result.dependencyCount += 1 - workspaces.forEach((workspace) => browserWorkspaceIds.add(workspace.id)) - } - for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { - if (!matchesOwner(ownerKey)) { - continue - } - for (const tab of tabs) { - if (tab.contentType === 'terminal') { - result.tabIds.add(tab.entityId) - } else if (tab.contentType === 'browser') { - browserWorkspaceIds.add(tab.entityId) - } - } - } - result.dependencyCount += countOwnedRecordKeys(session, matchesOwner) - result.dependencyCount += Object.keys(session.browserPagesByWorkspace ?? {}).filter( - (workspaceId) => browserWorkspaceIds.has(workspaceId) - ).length - result.dependencyCount += Object.keys(session.terminalLayoutsByTabId).filter((tabId) => - result.tabIds.has(tabId) - ).length - result.dependencyCount += Object.keys(session.remoteSessionIdsByTabId ?? {}).filter((tabId) => - result.tabIds.has(tabId) - ).length - for (const paneKey of Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {})) { - if (orcadMigrationPaneBelongsToTabs(paneKey, result.tabIds)) { - result.dependencyCount += 1 - } - } - for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { - if (matchesOwner(tombstone.worktreeId)) { - result.dependencyCount += 1 - result.ptyIds.add(tombstone.ptyId) - } - } - for (const sleeping of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { - if (matchesOwner(sleeping.worktreeId) || sleeping.connectionId === scope.targetId) { - result.dependencyCount += 1 - } - } - result.dependencyCount += (session.activeWorktreeIdsOnShutdown ?? []).filter(matchesOwner).length - result.dependencyCount += session.activeRepoId && scope.ownerMatches(session.activeRepoId) ? 1 : 0 - result.dependencyCount += matchesOwner(session.activeWorktreeId ?? '') ? 1 : 0 - result.dependencyCount += matchesOwner(session.activeWorkspaceKey ?? '') ? 1 : 0 - result.dependencyCount += session.activeWorkspaceExecutionHostId === scope.hostId ? 1 : 0 - result.dependencyCount += (session.activeConnectionIdsAtShutdown ?? []).filter( - (targetId) => targetId === scope.targetId - ).length - result.dependencyCount += session.activeTabId && result.tabIds.has(session.activeTabId) ? 1 : 0 - return result -} - -function countOwnedRecordKeys( - session: WorkspaceSessionState, - matchesOwner: (ownerKey: string) => boolean -): number { - let count = 0 - for (const field of SESSION_FIELDS_PRUNED_BY_OWNER_KEY) { - count += Object.keys(session[field] ?? {}).filter(matchesOwner).length - } - return count -} - -export function orcadMigrationPaneBelongsToTabs( - paneKey: string, - tabIds: ReadonlySet -): boolean { - for (const tabId of tabIds) { - if (paneKey.startsWith(`${tabId}:`)) { - return true - } - } - return false -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts deleted file mode 100644 index 2a43579a9b8b..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { isWorkspaceKey } from '../../../shared/workspace-scope' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { buildMarkdownFrontmatterIdMap } from '../../orca-profiles/profile-session-owner-transfer' -import { - orcadMigrationOwnerMatchesScope, - unqualifyOrcadMigrationOwnerKey, - type OrcadMigrationSourceScope -} from './orcad-source-scope' -import { - paneBelongsToTabs, - paneBelongsToTerminalLayout -} from './orcad-source-workspace-session-layout' -import { collectSessionOwnerKeys } from './orcad-source-workspace-session-fragments' - -export function countUnrepresentableMarkdownState( - session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope, - sourceHostPartition: boolean -): number { - const projection = { - mapOwnerKey: (ownerKey: string) => - sourceHostPartition || orcadMigrationOwnerMatchesScope(ownerKey, scope) - ? unqualifyOrcadMigrationOwnerKey(ownerKey) - : null, - mapWorktreeId: unqualifyOrcadMigrationOwnerKey - } - const mappings = buildMarkdownFrontmatterIdMap(session.openFilesByWorktree, projection) - return Object.keys(session.markdownFrontmatterVisible ?? {}).filter( - (fileId) => mappings.get(fileId) === null - ).length -} - -export function countUnsupportedSessionState( - state: PersistedState, - session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope, - sourceHostPartition: boolean, - terminalTabIds: ReadonlySet -): number { - const owns = (ownerKey: string): boolean => orcadMigrationOwnerMatchesScope(ownerKey, scope) - const matches = (ownerKey: string): boolean => - Boolean(ownerKey) && (sourceHostPartition || owns(ownerKey)) - let count = sourceHostPartition - ? [...collectSessionOwnerKeys(session)].filter((ownerKey) => !owns(ownerKey)).length - : 0 - for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { - if (!owns(ownerKey)) { - continue - } - tabs.forEach((tab) => { - count += tab.ptyId ? 1 : 0 - }) - } - for (const tabId of terminalTabIds) { - const layout = session.terminalLayoutsByTabId[tabId] - count += Object.keys(layout?.ptyIdsByLeafId ?? {}).length - count += session.remoteSessionIdsByTabId?.[tabId] ? 1 : 0 - } - count += Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).filter((paneKey) => - paneBelongsToTabs(paneKey, terminalTabIds) - ).length - count += Object.values(session.sleepingAgentSessionsByPaneKey ?? {}).filter((record) => { - const touchesSource = matches(record.worktreeId) || record.connectionId === scope.targetId - return ( - touchesSource && !transferableSleepingAgentSession(record, session, scope, terminalTabIds) - ) - }).length - count += Object.entries(session.clientHostedBrowserPagesByWorktree ?? {}) - .filter(([ownerKey]) => matches(ownerKey)) - .filter( - ([ownerKey, pages]) => !clientHostedPagesAreTransferable(session, ownerKey, pages) - ).length - count += (session.activeWorktreeIdsOnShutdown ?? []).filter( - (worktreeId) => - matches(worktreeId) && - shutdownMarkerHasTerminalAuthority(state, session, scope, sourceHostPartition, worktreeId) - ).length - count += - session.activeRepoId && - owns(session.activeRepoId) && - !(sourceHostPartition && scope.repoIds.has(session.activeRepoId)) - ? 1 - : 0 - count += - session.activeWorktreeId && - matches(session.activeWorktreeId) && - !(sourceHostPartition && orcadMigrationOwnerMatchesScope(session.activeWorktreeId, scope)) - ? 1 - : 0 - count += - session.activeWorkspaceKey && - matches(session.activeWorkspaceKey) && - !(sourceHostPartition && orcadMigrationOwnerMatchesScope(session.activeWorkspaceKey, scope)) - ? 1 - : 0 - count += session.activeWorkspaceExecutionHostId === scope.hostId && !sourceHostPartition ? 1 : 0 - count += (session.activeConnectionIdsAtShutdown ?? []).filter( - (targetId) => targetId === scope.targetId - ).length - count += - session.activeTabId && terminalTabIds.has(session.activeTabId) && !sourceHostPartition ? 1 : 0 - for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { - if (owns(ownerKey)) { - count += files.filter( - (file) => file.externalSshTargetId !== undefined || Boolean(file.runtimeEnvironmentId) - ).length - } - } - for (const [ownerKey, workspaces] of Object.entries(session.browserTabsByWorktree ?? {})) { - if (owns(ownerKey)) { - count += workspaces.filter((workspace) => - Boolean(workspace.sessionProfileId || workspace.sessionPartition) - ).length - } - } - for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { - if (owns(ownerKey)) { - count += tabs.filter( - (tab) => tab.executionHostId !== undefined && tab.executionHostId !== scope.hostId - ).length - } - } - return count -} - -export function shutdownMarkerHasTerminalAuthority( - state: PersistedState, - session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope, - sourceHostPartition: boolean, - worktreeId: string -): boolean { - const marker = unqualifyOrcadMigrationOwnerKey(worktreeId) - const ownerMatchesMarker = (ownerKey: string): boolean => - (sourceHostPartition || orcadMigrationOwnerMatchesScope(ownerKey, scope)) && - unqualifyOrcadMigrationOwnerKey(ownerKey) === marker - const tabIds = new Set() - for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { - if (!ownerMatchesMarker(ownerKey)) { - continue - } - for (const tab of tabs) { - tabIds.add(tab.id) - if (tab.ptyId) { - return true - } - const layout = session.terminalLayoutsByTabId[tab.id] - if (Object.keys(layout?.ptyIdsByLeafId ?? {}).length > 0) { - return true - } - if (session.remoteSessionIdsByTabId?.[tab.id]) { - return true - } - } - } - if ( - Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).some((paneKey) => - paneBelongsToTabs(paneKey, tabIds) - ) - ) { - return true - } - return state.sshRemotePtyLeases.some( - (lease) => - lease.targetId === scope.targetId && - lease.state !== 'terminated' && - (lease.worktreeId === undefined || - unqualifyOrcadMigrationOwnerKey(lease.worktreeId) === marker) - ) -} - -function clientHostedPagesAreTransferable( - session: WorkspaceSessionState, - ownerKey: string, - pages: NonNullable[string] -): boolean { - const browserWorkspaceIds = new Set( - (session.browserTabsByWorktree?.[ownerKey] ?? []).map((workspace) => workspace.id) - ) - return pages.every((page) => browserWorkspaceIds.has(page.workspaceId)) -} - -export function projectDormantSessionFocus( - source: WorkspaceSessionState, - transferred: WorkspaceSessionState, - scope: OrcadMigrationSourceScope, - terminalTabIds: ReadonlySet -): void { - // These scalars are UI focus, not execution ownership. They are safe to carry - // only from the source host partition and only when they point at an entity - // already proven dormant and included in the projected session. - if (source.activeRepoId && scope.repoIds.has(source.activeRepoId)) { - transferred.activeRepoId = source.activeRepoId - } - if (source.activeWorktreeId && orcadMigrationOwnerMatchesScope(source.activeWorktreeId, scope)) { - transferred.activeWorktreeId = unqualifyOrcadMigrationOwnerKey(source.activeWorktreeId) - } - const activeWorkspaceKey = - source.activeWorkspaceKey && orcadMigrationOwnerMatchesScope(source.activeWorkspaceKey, scope) - ? unqualifyOrcadMigrationOwnerKey(source.activeWorkspaceKey) - : null - if (activeWorkspaceKey && isWorkspaceKey(activeWorkspaceKey)) { - transferred.activeWorkspaceKey = activeWorkspaceKey - } - if (source.activeWorkspaceExecutionHostId === scope.hostId) { - transferred.activeWorkspaceExecutionHostId = LOCAL_EXECUTION_HOST_ID - } - if (source.activeTabId && terminalTabIds.has(source.activeTabId)) { - transferred.activeTabId = source.activeTabId - } -} - -export function projectSessionToDestination( - session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope -): WorkspaceSessionState { - const projected = structuredClone(session) - for (const tabs of Object.values(projected.unifiedTabs ?? {})) { - for (const tab of tabs) { - if (tab.executionHostId === scope.hostId) { - tab.executionHostId = LOCAL_EXECUTION_HOST_ID - } - } - } - return projected -} - -export function transferableSleepingAgentSession( - record: SleepingAgentSessionRecord, - session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope, - terminalTabIds: ReadonlySet -): boolean { - return ( - orcadMigrationOwnerMatchesScope(record.worktreeId, scope) && - (record.connectionId == null || record.connectionId === scope.targetId) && - // Older profiles can still contain a worker-resume fence; never discard its authority. - (!('automaticResumeBlockedBy' in record) || record.automaticResumeBlockedBy === undefined) && - ((record.origin ?? 'worktree-sleep') === 'worktree-sleep' || - paneBelongsToTerminalLayout(record, session, terminalTabIds)) - ) -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts deleted file mode 100644 index 3f3bf33da9eb..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { mergeWorkspaceSessions } from '../../orca-profiles/profile-project-session-state' -import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition' - -export function sessionPartitions( - state: { - workspaceSession: WorkspaceSessionState - workspaceSessionsByHostId?: Record - }, - localHostId: string -): [string, WorkspaceSessionState][] { - return [ - [localHostId, state.workspaceSession], - ...Object.entries(state.workspaceSessionsByHostId ?? {}).flatMap( - ([hostId, session]): [string, WorkspaceSessionState][] => (session ? [[hostId, session]] : []) - ) - ] -} - -export function mergeSessionFragments( - fragments: WorkspaceSessionState[] -): WorkspaceSessionState | null { - const ownerKeys = new Set() - const entityKeys = new Set() - let merged: WorkspaceSessionState | undefined - for (const fragment of fragments) { - if ( - hasDuplicates(ownerKeys, collectSessionOwnerKeys(fragment)) || - hasDuplicates(entityKeys, collectSessionEntityKeys(fragment)) - ) { - return null - } - merged = mergeWorkspaceSessions(merged, fragment) - } - return merged ?? null -} - -export function collectSessionOwnerKeys(session: WorkspaceSessionState): Set { - const keys = new Set() - const fields = [ - 'tabsByWorktree', - 'openFilesByWorktree', - 'browserTabsByWorktree', - 'unifiedTabs', - 'tabGroups', - ...SESSION_FIELDS_PRUNED_BY_OWNER_KEY - ] as const - for (const field of fields) { - Object.keys(session[field] ?? {}).forEach((key) => keys.add(key)) - } - Object.values(session.tabsByWorktree) - .flat() - .forEach((entry) => keys.add(entry.worktreeId)) - Object.values(session.openFilesByWorktree ?? {}) - .flat() - .forEach((entry) => keys.add(entry.worktreeId)) - Object.values(session.browserTabsByWorktree ?? {}) - .flat() - .forEach((entry) => keys.add(entry.worktreeId)) - Object.values(session.browserPagesByWorkspace ?? {}) - .flat() - .forEach((entry) => keys.add(entry.worktreeId)) - Object.values(session.unifiedTabs ?? {}) - .flat() - .forEach((entry) => keys.add(entry.worktreeId)) - Object.values(session.tabGroups ?? {}) - .flat() - .forEach((entry) => keys.add(entry.worktreeId)) - Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {}).forEach((entry) => - keys.add(entry.worktreeId) - ) - Object.values(session.sleepingAgentSessionsByPaneKey ?? {}).forEach((entry) => - keys.add(entry.worktreeId) - ) - return keys -} - -export function collectSessionEntityKeys(session: WorkspaceSessionState): string[] { - const keys: string[] = [] - Object.values(session.tabsByWorktree) - .flat() - .forEach((tab) => keys.push(`terminal:${tab.id}`)) - Object.values(session.browserTabsByWorktree ?? {}) - .flat() - .forEach((tab) => keys.push(`browser:${tab.id}`)) - Object.values(session.clientHostedBrowserPagesByWorktree ?? {}) - .flat() - .forEach((page) => keys.push(`client-browser-page:${page.browserPageId}`)) - Object.values(session.unifiedTabs ?? {}) - .flat() - .forEach((tab) => keys.push(`tab:${tab.id}`)) - Object.values(session.tabGroups ?? {}) - .flat() - .forEach((group) => keys.push(`group:${group.id}`)) - Object.keys(session.terminalSurfaceTombstonesByPaneKey ?? {}).forEach((key) => - keys.push(`tombstone:${key}`) - ) - Object.keys(session.sleepingAgentSessionsByPaneKey ?? {}).forEach((key) => - keys.push(`sleeping-agent:${key}`) - ) - return keys -} - -function hasDuplicates(seen: Set, incoming: Iterable): boolean { - let duplicate = false - for (const key of incoming) { - if (seen.has(key)) { - duplicate = true - } - seen.add(key) - } - return duplicate -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts deleted file mode 100644 index c541bad0cba9..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { - orcadMigrationOwnerMatchesScope, - type OrcadMigrationSourceScope -} from './orcad-source-scope' - -export function collectOwnedTerminalTabIds( - session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope -): Set { - const tabIds = new Set( - Object.entries(session.tabsByWorktree).flatMap(([ownerKey, tabs]) => - orcadMigrationOwnerMatchesScope(ownerKey, scope) ? tabs.map((tab) => tab.id) : [] - ) - ) - for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { - if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { - continue - } - for (const tab of tabs) { - if (tab.contentType === 'terminal') { - tabIds.add(tab.id) - tabIds.add(tab.entityId) - } - } - } - return tabIds -} - -export function paneBelongsToTerminalLayout( - record: SleepingAgentSessionRecord, - session: WorkspaceSessionState, - terminalTabIds: ReadonlySet -): boolean { - const separator = record.paneKey.lastIndexOf(':') - if (separator < 1) { - return false - } - const tabId = record.paneKey.slice(0, separator) - const leafId = record.paneKey.slice(separator + 1) - if ((record.tabId !== undefined && record.tabId !== tabId) || !terminalTabIds.has(tabId)) { - return false - } - return terminalLayoutContainsLeaf(session.terminalLayoutsByTabId[tabId]?.root, leafId) -} - -function terminalLayoutContainsLeaf( - node: WorkspaceSessionState['terminalLayoutsByTabId'][string]['root'] | undefined, - leafId: string -): boolean { - return Boolean( - node && - (node.type === 'leaf' - ? node.leafId === leafId - : terminalLayoutContainsLeaf(node.first, leafId) || - terminalLayoutContainsLeaf(node.second, leafId)) - ) -} - -export function paneBelongsToTabs(paneKey: string, tabIds: ReadonlySet): boolean { - const separator = paneKey.lastIndexOf(':') - return separator > 0 && tabIds.has(paneKey.slice(0, separator)) -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts deleted file mode 100644 index e56a1943a570..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationManifestSource -} from '../../../shared/orcad-migration-manifest' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../../shared/orcad-migration-scrollback' -import { - extractSessionOwnersForTransfer, - hasTransferredSessionState -} from '../../orca-profiles/profile-session-owner-transfer' -import type { TerminalScrollbackSnapshotStorage } from '../../terminal-scrollback-snapshots' -import { - createOrcadMigrationSourceScope, - orcadMigrationOwnerMatchesScope, - unqualifyOrcadMigrationOwnerKey -} from './orcad-source-scope' -import { - countUnrepresentableMarkdownState, - countUnsupportedSessionState, - projectDormantSessionFocus, - projectSessionToDestination, - shutdownMarkerHasTerminalAuthority, - transferableSleepingAgentSession -} from './orcad-source-workspace-session-eligibility' -import { collectOwnedTerminalTabIds } from './orcad-source-workspace-session-layout' -import { - mergeSessionFragments, - sessionPartitions -} from './orcad-source-workspace-session-fragments' -import { - hasDuplicateOrcadMigrationScrollbackDescriptors, - projectOrcadMigrationSessionScrollback -} from './orcad-source-scrollback-state' - -export type OrcadMigrationSourceWorkspaceSessionInspection = { - payload: WorkspaceSessionState | undefined - snapshots: OrcadMigrationTerminalScrollbackSnapshot[] - blockedCount: number -} - -export function collectOrcadMigrationSourceWorkspaceSession( - state: PersistedState, - source: OrcadMigrationManifestSource, - catalog: OrcadMigrationCatalogPayload, - storage?: TerminalScrollbackSnapshotStorage -): OrcadMigrationSourceWorkspaceSessionInspection { - const scope = createOrcadMigrationSourceScope({ source, catalog }) - const fragments: WorkspaceSessionState[] = [] - const snapshots: OrcadMigrationTerminalScrollbackSnapshot[] = [] - let blockedCount = 0 - for (const [partitionId, session] of sessionPartitions(state, LOCAL_EXECUTION_HOST_ID)) { - const sourceHostPartition = partitionId === scope.hostId - const terminalTabIds = collectOwnedTerminalTabIds(session, scope) - blockedCount += countUnsupportedSessionState( - state, - session, - scope, - sourceHostPartition, - terminalTabIds - ) - blockedCount += countUnrepresentableMarkdownState(session, scope, sourceHostPartition) - const fragment = extractSessionOwnersForTransfer(session, { - mapOwnerKey: (ownerKey) => - orcadMigrationOwnerMatchesScope(ownerKey, scope) - ? unqualifyOrcadMigrationOwnerKey(ownerKey) - : null, - mapWorktreeId: unqualifyOrcadMigrationOwnerKey, - projectSessionFocus: sourceHostPartition - ? ({ source, transferred, terminalTabIds }) => - projectDormantSessionFocus(source, transferred, scope, terminalTabIds) - : undefined, - projectSleepingAgentSession: (record) => - transferableSleepingAgentSession(record, session, scope, terminalTabIds) - ? { - ...structuredClone(record), - worktreeId: unqualifyOrcadMigrationOwnerKey(record.worktreeId), - connectionId: null - } - : null - }) - // A shutdown marker is only a reconnect hint. Once the source has no live - // PTY authority for that worktree, carrying it would make the destination - // try to resurrect a process that no longer exists. - if (fragment.activeWorktreeIdsOnShutdown) { - fragment.activeWorktreeIdsOnShutdown = fragment.activeWorktreeIdsOnShutdown.filter( - (worktreeId) => - shutdownMarkerHasTerminalAuthority(state, session, scope, sourceHostPartition, worktreeId) - ) - if (fragment.activeWorktreeIdsOnShutdown.length === 0) { - delete fragment.activeWorktreeIdsOnShutdown - } - } - if (hasTransferredSessionState(fragment)) { - const projected = projectOrcadMigrationSessionScrollback( - projectSessionToDestination(fragment, scope), - storage - ) - blockedCount += projected.blockedCount - snapshots.push(...projected.snapshots) - fragments.push(projected.session) - } - } - if (hasDuplicateOrcadMigrationScrollbackDescriptors(snapshots)) { - blockedCount += 1 - } - if (blockedCount > 0 || fragments.length === 0) { - return { payload: undefined, snapshots: [], blockedCount } - } - const merged = mergeSessionFragments(fragments) - return merged - ? { payload: merged, snapshots, blockedCount: 0 } - : { payload: undefined, snapshots: [], blockedCount: 1 } -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts deleted file mode 100644 index 930bf5d18865..000000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { WorktreeMeta } from '../../../shared/worktree/meta-types' -import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' -import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' -import { canonicalWorktreeIdentity } from '../../../shared/worktree/identity' -import { - getExecutionHostIdFromWorktreeHostIdentity, - isWorktreeHostIdentity -} from '../../../shared/worktree/host-qualified-identity' -import { pruneUnreferencedWorktreeIdentityMeta } from '../loading-store/worktree-identity-metadata' -import { - createOrcadMigrationSourceScope, - orcadMigrationOwnerMatchesScope, - unqualifyOrcadMigrationOwnerKey, - type OrcadMigrationSourceScope -} from './orcad-source-scope' - -export function inspectOrcadSourceWorktreeMetadata( - state: PersistedState, - scope: OrcadMigrationSourceScope -) { - const rows: { sourceKey: string; meta: WorktreeMeta }[] = [] - let blockedCount = 0 - for (const [sourceKey, meta] of Object.entries(state.worktreeMeta)) { - const host = getExecutionHostIdFromWorktreeHostIdentity(sourceKey) - if ((host && host !== scope.hostId) || (meta.hostId && meta.hostId !== scope.hostId)) { - if (host === scope.hostId || meta.hostId === scope.hostId) { - blockedCount++ - } - continue - } - if (orcadMigrationOwnerMatchesScope(sourceKey, scope)) { - rows.push({ sourceKey, meta }) - } else if (meta.hostId === scope.hostId || host === scope.hostId) { - blockedCount++ - } - } - const referenced = new Set(Object.values(state.worktreeIdentityAliases ?? {}).flat()) - for (const [identity, meta] of Object.entries(state.worktreeMetaByIdentity ?? {})) { - if (meta.hostId === scope.hostId && !referenced.has(identity)) { - blockedCount++ - } - } - for (const [alias, identities] of Object.entries(state.worktreeIdentityAliases ?? {})) { - if (getExecutionHostIdFromWorktreeHostIdentity(alias) !== scope.hostId) { - continue - } - const meta = identities.length === 1 ? state.worktreeMetaByIdentity?.[identities[0]] : undefined - const worktreeId = unqualifyOrcadMigrationOwnerKey(alias) - if ( - isWorktreeHostIdentity(worktreeId) || - !orcadMigrationOwnerMatchesScope(alias, scope) || - !meta || - !meta.instanceId || - (meta.hostId !== undefined && meta.hostId !== scope.hostId) || - identities[0] !== - canonicalWorktreeIdentity({ - worktreeId, - executionHostId: scope.hostId, - instanceId: meta.instanceId - }) - ) { - blockedCount++ - continue - } - const legacy = rows.filter( - (row) => unqualifyOrcadMigrationOwnerKey(row.sourceKey) === worktreeId - ) - if ( - legacy.length > 1 || - (legacy.length === 1 && - serializeOrcadMigrationValue({ ...legacy[0].meta, hostId: scope.hostId }) !== - serializeOrcadMigrationValue({ ...meta, hostId: scope.hostId })) - ) { - // Neither representation may silently discard data held only by its competing row. - blockedCount++ - continue - } - if (legacy.length === 0) { - rows.push({ sourceKey: alias, meta: { ...meta, hostId: scope.hostId } }) - } - } - return { rows, blockedCount } -} - -export function retireOrcadSourceWorktreeMetadata( - state: PersistedState, - manifest: OrcadMigrationManifest -) { - const scope = createOrcadMigrationSourceScope({ - source: manifest.source, - catalog: manifest.payload - }) - const entries = manifest.payload.dormantState?.worktreeMeta ?? [] - const worktreeIds = new Set(entries.map((entry) => entry.worktreeId)) - const removedIdentities = new Set() - for (const [alias, identities] of Object.entries(state.worktreeIdentityAliases ?? {})) { - if ( - getExecutionHostIdFromWorktreeHostIdentity(alias) === scope.hostId && - worktreeIds.has(unqualifyOrcadMigrationOwnerKey(alias)) - ) { - identities.forEach((identity) => removedIdentities.add(identity)) - delete state.worktreeIdentityAliases?.[alias] - } - } - entries.forEach((entry) => delete state.worktreeMeta[entry.sourceKey]) - pruneUnreferencedWorktreeIdentityMeta(state, removedIdentities) -} - -export function assertOrcadSourceWorktreeMetadataRetired( - state: PersistedState, - manifest: OrcadMigrationManifest -) { - const scope = createOrcadMigrationSourceScope({ - source: manifest.source, - catalog: manifest.payload - }) - const inspection = inspectOrcadSourceWorktreeMetadata(state, scope) - if (inspection.rows.length || inspection.blockedCount) { - throw new Error('orcad_migration_source_worktree_metadata_reappeared') - } -} diff --git a/src/main/ssh/orcad-migration-manifest-export.test.ts b/src/main/ssh/orcad-migration-manifest-export.test.ts deleted file mode 100644 index 8637dca12b27..000000000000 --- a/src/main/ssh/orcad-migration-manifest-export.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { FolderWorkspace } from '../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../shared/project-group-types' -import type { Repo } from '../../shared/repo-types' -import type { SshTarget } from '../../shared/ssh-types' -import { computeOrcadMigrationManifestSha256 } from '../orcad/orcad-migration-manifest-digest' -import { emptyDormantPayload } from '../persistence/migrating-orcad-catalog/orcad-source-dormant-state' -import { createOrcadMigrationManifest } from './orcad-migration-manifest-export' - -const TARGET: SshTarget = { - id: 'ssh-prod', - label: 'Production', - host: 'prod.example.com', - port: 22, - username: 'deploy', - generation: 7 -} - -function repo(id: string, connectionId: string, projectGroupId?: string): Repo { - return { - id, - path: `/srv/${id}`, - displayName: id, - badgeColor: '#737373', - addedAt: 1, - kind: 'git', - connectionId, - ...(projectGroupId ? { projectGroupId } : {}) - } -} - -function group( - id: string, - connectionId: string | null, - parentGroupId: string | null = null -): ProjectGroup { - return { - id, - name: id, - parentPath: `/srv/${id}`, - connectionId, - parentGroupId, - createdFrom: 'manual', - tabOrder: 1, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } -} - -function folder(id: string, projectGroupId: string, connectionId?: string): FolderWorkspace { - return { - id, - projectGroupId, - name: id, - folderPath: `/srv/${id}`, - ...(connectionId ? { connectionId } : {}), - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1, - lastActivityAt: 0, - createdAt: 1, - updatedAt: 1 - } -} - -describe('orcad migration manifest export', () => { - it('exports only the target catalog plus referenced group ancestry', () => { - const groups = [ - group('parent', null), - group('repo-group', 'ssh-prod', 'parent'), - group('folder-group', 'ssh-prod'), - group('other-group', 'ssh-other') - ] - const manifest = createOrcadMigrationManifest( - { - collectOrcadMigrationSourceDormantState: emptyDormantPayload, - getRepos: () => [ - repo('repo-prod', 'ssh-prod', 'repo-group'), - repo('repo-other', 'ssh-other') - ], - getProjectGroups: () => groups, - getFolderWorkspaces: () => [ - folder('folder-inherited', 'folder-group'), - folder('folder-explicit', 'folder-group', 'ssh-prod'), - folder('folder-other', 'other-group', 'ssh-other') - ] - }, - TARGET, - { migrationId: 'migration-1', now: () => new Date('2026-08-30T12:00:00.000Z') } - ) - - expect(manifest.payload.repositories.map((entry) => entry.id)).toEqual(['repo-prod']) - expect(manifest.payload.projectGroups.map((entry) => entry.id)).toEqual([ - 'parent', - 'repo-group', - 'folder-group' - ]) - expect(manifest.payload.folderWorkspaces.map((entry) => entry.id)).toEqual([ - 'folder-inherited', - 'folder-explicit' - ]) - expect(manifest.source).toEqual({ - sshTargetId: 'ssh-prod', - sshTargetGeneration: 7, - targetLabel: 'Production' - }) - const { manifestSha256, ...unsigned } = manifest - expect(manifestSha256).toBe(computeOrcadMigrationManifestSha256(unsigned)) - }) - - it('records a null generation for a legacy registration without mutating it', () => { - const target = { ...TARGET, generation: undefined } - const manifest = createOrcadMigrationManifest( - { - collectOrcadMigrationSourceDormantState: emptyDormantPayload, - getRepos: () => [], - getProjectGroups: () => [], - getFolderWorkspaces: () => [] - }, - target, - { migrationId: 'migration-legacy', now: () => new Date('2026-08-30T12:00:00.000Z') } - ) - - expect(manifest.source.sshTargetGeneration).toBeNull() - expect(target.generation).toBeUndefined() - }) - - it('includes a session-only dormant payload', () => { - const dormant = emptyDormantPayload() - dormant.workspaceSession = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - 'repo-prod::/srv/worktree': [ - { - id: 'tab-dormant', - ptyId: null, - worktreeId: 'repo-prod::/srv/worktree', - title: 'Dormant', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } - } - const manifest = createOrcadMigrationManifest( - { - collectOrcadMigrationSourceDormantState: () => dormant, - getRepos: () => [repo('repo-prod', TARGET.id)], - getProjectGroups: () => [], - getFolderWorkspaces: () => [] - }, - TARGET, - { migrationId: 'migration-session-only' } - ) - - expect(manifest.payload.dormantState?.workspaceSession?.tabsByWorktree).toHaveProperty( - 'repo-prod::/srv/worktree' - ) - }) -}) diff --git a/src/main/ssh/orcad-migration-manifest-export.ts b/src/main/ssh/orcad-migration-manifest-export.ts deleted file mode 100644 index a94c7610daba..000000000000 --- a/src/main/ssh/orcad-migration-manifest-export.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * The signed manifest a relay-hosted SSH target's state is exported as. - * - * Reads only: the catalog rows the target owns and the dormant state that references them, copied - * out of the profile-state store. The source keeps every row; retiring it happens only after the - * destination has verified and imported this exact manifest. - */ -import { randomUUID } from 'node:crypto' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest, - type OrcadMigrationDormantStatePayload, - type OrcadMigrationManifest -} from '../../shared/orcad-migration-manifest' -import type { SshTarget } from '../../shared/ssh-types' -import type { Store } from '../persistence' -import { collectOrcadMigrationSourceCatalog } from '../persistence/migrating-orcad-catalog/orcad-source-catalog' -import { computeOrcadMigrationManifestSha256 } from '../orcad/orcad-migration-manifest-digest' - -export type OrcadMigrationExportStore = Pick< - Store, - | 'collectOrcadMigrationSourceDormantState' - | 'getFolderWorkspaces' - | 'getProjectGroups' - | 'getRepos' -> - -export function createOrcadMigrationManifest( - store: OrcadMigrationExportStore, - target: SshTarget, - options: { migrationId?: string; now?: () => Date; destinationEnvironmentId?: string } = {} -): OrcadMigrationManifest { - const payload = collectOrcadMigrationSourceCatalog(store, target) - const source = { - sshTargetId: target.id, - sshTargetGeneration: target.generation ?? null, - targetLabel: target.label - } - const dormantState = store.collectOrcadMigrationSourceDormantState( - source, - payload, - options.destinationEnvironmentId - ) - const unsigned = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: options.migrationId ?? randomUUID(), - createdAt: (options.now ?? (() => new Date()))().toISOString(), - source, - payload: { - ...payload, - ...(hasDormantState(dormantState) ? { dormantState } : {}) - }, - ...(options.destinationEnvironmentId - ? { destinationEnvironmentId: options.destinationEnvironmentId } - : {}) - } - return parseOrcadMigrationManifest({ - ...unsigned, - manifestSha256: computeOrcadMigrationManifestSha256(unsigned) - }) -} - -function hasDormantState(state: OrcadMigrationDormantStatePayload): boolean { - return ( - state.worktreeMeta.length > 0 || - state.worktreeLineage.length > 0 || - state.workspaceLineage.length > 0 || - state.sparsePresets.length > 0 || - state.retiredWorktreeNames.length > 0 || - state.retiredWorktreeNamespaces.length > 0 || - state.workspaceSession !== undefined || - (state.terminalScrollbackSnapshots?.length ?? 0) > 0 || - (state.automations?.length ?? 0) > 0 || - (state.automationRuns?.length ?? 0) > 0 || - (state.clientState !== undefined && Object.keys(state.clientState).length > 0) - ) -} diff --git a/src/main/ssh/orcad-migration-snapshot-coordinator.test.ts b/src/main/ssh/orcad-migration-snapshot-coordinator.test.ts deleted file mode 100644 index 2a1b44170e4d..000000000000 --- a/src/main/ssh/orcad-migration-snapshot-coordinator.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { createHash } from 'node:crypto' -import { describe, expect, it, vi } from 'vitest' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - type OrcadMigrationCatalogState, - type OrcadMigrationManifest -} from '../../shared/orcad-migration-manifest' -import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../shared/orcad-migration-scrollback' -import { transferOrcadMigrationSnapshots } from './orcad-migration-snapshot-coordinator' - -const BYTES = Buffer.from('resume these bytes', 'utf8') -const SNAPSHOT: OrcadMigrationTerminalScrollbackSnapshot = { - tabId: 'tab-1', - leafId: 'leaf-1', - ref: `v1-${'1'.repeat(32)}`, - sha256: createHash('sha256').update(BYTES).digest('hex'), - byteLength: BYTES.length -} -const MANIFEST: OrcadMigrationManifest = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: 'migration-1', - createdAt: '2026-08-30T12:00:00.000Z', - source: { sshTargetId: 'source', sshTargetGeneration: 1, targetLabel: 'Source' }, - payload: { - repositories: [], - projectGroups: [], - folderWorkspaces: [], - dormantState: { - version: 1, - worktreeMeta: [], - worktreeLineage: [], - workspaceLineage: [], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [], - terminalScrollbackSnapshots: [SNAPSHOT] - } - }, - manifestSha256: 'a'.repeat(64) -} -type ChunkReader = ( - manifest: OrcadMigrationManifest, - ref: string, - offset: number -) => { bytesBase64: string; totalBytes: number; eof: boolean } - -function source(read: ChunkReader) { - return { - readOrcadMigrationSourceSnapshotChunk: read, - retainOrcadMigrationScrollback: vi.fn(), - releaseOrcadMigrationScrollback: vi.fn() - } -} - -const unreachableRead: ChunkReader = () => { - throw new Error('must not read') -} - -describe('orcad migration snapshot coordinator', () => { - it('resumes at the observed offset and reconciles a lost chunk response', async () => { - const initialOffset = 4 - const sourceRead = vi.fn(() => ({ - bytesBase64: BYTES.subarray(initialOffset).toString('base64'), - totalBytes: BYTES.length, - eof: true - })) - const store = source(sourceRead) - const snapshotRequest = vi.fn() - const remoteReads = [staged(BYTES.length), staged(BYTES.length)] - - await transferOrcadMigrationSnapshots({ - source: store, - manifest: MANIFEST, - state: staged(initialOffset), - destination: { - stageChunk: async (request) => { - snapshotRequest(request) - throw new Error('response lost') - }, - readState: async () => nextState(remoteReads) - } - }) - - expect(sourceRead).toHaveBeenCalledWith(MANIFEST, SNAPSHOT.ref, initialOffset) - // The bytes stay retained exactly for the transfer's duration. - expect(store.retainOrcadMigrationScrollback).toHaveBeenCalledWith(MANIFEST) - expect(store.releaseOrcadMigrationScrollback).toHaveBeenCalledWith(MANIFEST.migrationId) - expect(snapshotRequest).toHaveBeenCalledWith( - expect.objectContaining({ offset: initialOffset, ref: SNAPSHOT.ref }) - ) - expect(remoteReads).toEqual([]) - }) - - it('fails closed when an old host omits snapshot upload state', async () => { - const sourceRead = vi.fn(unreachableRead) - const store = source(sourceRead) - await expect( - transferOrcadMigrationSnapshots({ - source: store, - manifest: MANIFEST, - state: staged(), - destination: { - stageChunk: async () => { - throw new Error('must not upload') - }, - readState: async () => staged() - } - }) - ).rejects.toThrow('orcad_migration_snapshot_transfer_unsupported') - expect(sourceRead).not.toHaveBeenCalled() - // A failed transfer still releases what it retained. - expect(store.releaseOrcadMigrationScrollback).toHaveBeenCalledOnce() - }) - - it('refuses a snapshot whose source length changed since export', async () => { - await expect( - transferOrcadMigrationSnapshots({ - source: source(() => ({ - bytesBase64: Buffer.from('changed').toString('base64'), - totalBytes: BYTES.length + 1, - eof: true - })), - manifest: MANIFEST, - state: staged(0), - destination: { - stageChunk: async () => { - throw new Error('must not upload') - }, - readState: async () => staged(0) - } - }) - ).rejects.toThrow('orcad_migration_source_snapshot_changed') - }) - - it('requires complete upload evidence after the final chunk', async () => { - const remoteReads = [staged(BYTES.length - 1)] - await expect( - transferOrcadMigrationSnapshots({ - source: source(unreachableRead), - manifest: MANIFEST, - state: staged(BYTES.length), - destination: { - stageChunk: async () => { - throw new Error('must not upload') - }, - readState: async () => nextState(remoteReads) - } - }) - ).rejects.toThrow('orcad_migration_snapshot_transfer_incomplete') - }) -}) - -function staged(receivedBytes?: number): Extract { - return { - state: 'staged', - migrationId: MANIFEST.migrationId, - manifestSha256: MANIFEST.manifestSha256, - stagedAt: '2026-08-30T12:01:00.000Z', - ...(receivedBytes === undefined ? {} : { snapshotUploads: [{ ...SNAPSHOT, receivedBytes }] }) - } -} - -function nextState( - states: Extract[] -): Extract { - const state = states.shift() - if (!state) { - throw new Error('unexpected remote read') - } - return state -} diff --git a/src/main/ssh/orcad-migration-snapshot-coordinator.ts b/src/main/ssh/orcad-migration-snapshot-coordinator.ts deleted file mode 100644 index 86e129f345f7..000000000000 --- a/src/main/ssh/orcad-migration-snapshot-coordinator.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Sending a manifest's scrollback snapshots to the destination in bounded, resumable chunks. - * - * The source side only reads: each chunk comes from the profile-state store, checked against the - * signed manifest's length and digest. The destination's catalog operations are passed in, so - * this driver owns no transport and retires nothing. A chunk whose acknowledgement was lost is - * confirmed by reading the destination's recorded offset, never assumed. - */ -import type { - OrcadMigrationCatalogState, - OrcadMigrationManifest -} from '../../shared/orcad-migration-manifest' -import { - decodeOrcadMigrationSnapshotChunk, - type OrcadMigrationSnapshotChunkRequest, - type OrcadMigrationSnapshotChunkResult -} from '../../shared/orcad-migration-scrollback' -import type { Store } from '../persistence' - -export type OrcadMigrationSnapshotSource = Pick< - Store, - | 'readOrcadMigrationSourceSnapshotChunk' - | 'retainOrcadMigrationScrollback' - | 'releaseOrcadMigrationScrollback' -> - -export type OrcadMigrationSnapshotDestination = { - readState: (manifest: OrcadMigrationManifest) => Promise - stageChunk: ( - request: OrcadMigrationSnapshotChunkRequest - ) => Promise -} - -export async function transferOrcadMigrationSnapshots(args: { - source: OrcadMigrationSnapshotSource - manifest: OrcadMigrationManifest - /** The destination's staged state, whose upload offsets say where each snapshot resumes. */ - state: Extract - destination: OrcadMigrationSnapshotDestination -}): Promise { - const snapshots = args.manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] - if (snapshots.length === 0) { - return - } - // Closing a tab mid-transfer must not delete the bytes this manifest promised. - args.source.retainOrcadMigrationScrollback(args.manifest) - try { - await sendSnapshots(args, snapshots) - } finally { - args.source.releaseOrcadMigrationScrollback(args.manifest.migrationId) - } -} - -async function sendSnapshots( - args: Parameters[0], - snapshots: NonNullable< - NonNullable['terminalScrollbackSnapshots'] - > -): Promise { - const offsets = new Map( - (args.state.snapshotUploads ?? []).map((entry) => [entry.ref, entry.receivedBytes]) - ) - for (const snapshot of snapshots) { - let offset = offsets.get(snapshot.ref) - if (offset === undefined) { - throw new Error('orcad_migration_snapshot_transfer_unsupported') - } - while (offset < snapshot.byteLength) { - const chunk = args.source.readOrcadMigrationSourceSnapshotChunk( - args.manifest, - snapshot.ref, - offset - ) - if (chunk.totalBytes !== snapshot.byteLength || !chunk.bytesBase64) { - throw new Error('orcad_migration_source_snapshot_changed') - } - const request: OrcadMigrationSnapshotChunkRequest = { - migrationId: args.manifest.migrationId, - manifestSha256: args.manifest.manifestSha256, - ref: snapshot.ref, - offset, - bytesBase64: chunk.bytesBase64 - } - const expectedOffset = offset + decodeOrcadMigrationSnapshotChunk(chunk.bytesBase64).length - offset = await stageChunkWithRecovery(args, request, expectedOffset) - } - } - const verified = await args.destination.readState(args.manifest) - if ( - verified.state !== 'staged' || - (verified.snapshotUploads ?? []).length !== snapshots.length || - (verified.snapshotUploads ?? []).some((entry) => entry.receivedBytes !== entry.byteLength) - ) { - throw new Error('orcad_migration_snapshot_transfer_incomplete') - } -} - -async function stageChunkWithRecovery( - args: { manifest: OrcadMigrationManifest; destination: OrcadMigrationSnapshotDestination }, - request: OrcadMigrationSnapshotChunkRequest, - expectedOffset: number -): Promise { - try { - const result = await args.destination.stageChunk(request) - if (result.acknowledgedOffset !== expectedOffset) { - throw new Error('orcad_migration_snapshot_ack_invalid') - } - return result.acknowledgedOffset - } catch (error) { - try { - const observed = await args.destination.readState(args.manifest) - const received = - observed.state === 'staged' - ? observed.snapshotUploads?.find((entry) => entry.ref === request.ref)?.receivedBytes - : undefined - if (received === expectedOffset) { - return received - } - } catch { - // The chunk stays unverifiable; report the first failure. - } - throw error - } -} diff --git a/src/main/ssh/ssh-target-orcad-claims.ts b/src/main/ssh/ssh-target-orcad-claims.ts index cfce055a9eec..fc389d3c44f4 100644 --- a/src/main/ssh/ssh-target-orcad-claims.ts +++ b/src/main/ssh/ssh-target-orcad-claims.ts @@ -107,17 +107,6 @@ export class SshTargetOrcadClaims { } function collectEmptyTargetBlockers(store: ClaimStore, target: SshTarget): OrcadMigrationBlocker[] { - return [ - ...collectTargetCatalogBlockers(store, target), - ...collectDependentStateBlockers(store, target.id) - ] -} - -/** Ownership, the catalog rows the target owns, and its saved port forwards. */ -export function collectTargetCatalogBlockers( - store: Pick, - target: SshTarget -): OrcadMigrationBlocker[] { const blockers: OrcadMigrationBlocker[] = [] if (target.owner) { blockers.push({ @@ -155,6 +144,7 @@ export function collectTargetCatalogBlockers( portForwards: target.portForwards.map((portForward) => ({ ...portForward })) }) } + blockers.push(...collectDependentStateBlockers(store, target.id)) return blockers } diff --git a/src/main/ssh/ssh-target-orcad-dependents.ts b/src/main/ssh/ssh-target-orcad-dependents.ts index 9845bb46a80c..bfac28d25ce2 100644 --- a/src/main/ssh/ssh-target-orcad-dependents.ts +++ b/src/main/ssh/ssh-target-orcad-dependents.ts @@ -5,13 +5,11 @@ import type { Store } from '../persistence' import { getDefaultWorkspaceSession } from '../../shared/constants' import { toSshExecutionHostId } from '../../shared/execution-host' -import { - ORCAD_MIGRATION_DEPENDENCY_KINDS, - type OrcadMigrationBlocker, - type OrcadMigrationDependency, - type OrcadMigrationDependencyKind +import type { + OrcadMigrationBlocker, + OrcadMigrationDependency, + OrcadMigrationDependencyKind } from '../../shared/orcad-migration-preflight' -import type { OrcadMigrationManifest } from '../../shared/orcad-migration-manifest' export type DependentStateStore = Pick< Store, @@ -78,40 +76,6 @@ export function collectDependentStateBlockers( return blockers } -/** Kinds with their own blockers (port forwards, terminal leases) are counted elsewhere. */ -const CENSUS_DEPENDENCY_KINDS = ORCAD_MIGRATION_DEPENDENCY_KINDS.filter( - (kind) => kind !== 'saved-port-forward' && kind !== 'terminal-lease' -) - -/** - * The export-aware census: only state that references the target and that this manifest cannot - * carry blocks. A census the store could not take is unverifiable, never an empty one. - */ -export function collectUntransferredDependentBlockers( - store: Pick, - manifest: OrcadMigrationManifest -): OrcadMigrationBlocker[] { - let counts: Record - try { - counts = store.inspectOrcadMigrationUntransferredDependencies(manifest).counts - } catch { - return [ - { - code: 'orcad_migration_dependency_unverifiable', - category: 'live-or-unverifiable', - sources: [...CENSUS_DEPENDENCY_KINDS] - } - ] - } - const dependencies = CENSUS_DEPENDENCY_KINDS.filter((kind) => counts[kind] > 0).map((kind) => ({ - kind, - count: counts[kind] - })) - return dependencies.length > 0 - ? [{ code: 'orcad_migration_dependent_state', category: 'client-owned-state', dependencies }] - : [] -} - /** Non-default fields of the host's session partition, plus a local session pointed at the host. */ function workspaceSessionReferences(store: DependentStateStore, hostId: string): string[] { const references: string[] = [] diff --git a/src/main/ssh/ssh-target-orcad-preflight.test.ts b/src/main/ssh/ssh-target-orcad-preflight.test.ts deleted file mode 100644 index 65905d9a50c2..000000000000 --- a/src/main/ssh/ssh-target-orcad-preflight.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { SshRemotePtyLease, SshTarget } from '../../shared/ssh-types' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' -import { Store } from '../persistence/loading-store/store' -import { - preflightOrcadMigrationExport, - type OrcadMigrationPreflightStore -} from './ssh-target-orcad-preflight' - -const TARGET: SshTarget = { - id: 'ssh-prod', - label: 'Production', - host: 'prod.example.com', - port: 22, - username: 'deploy', - generation: 2 -} - -const directories: string[] = [] -afterEach(async () => { - await closeTestStores() - for (const directory of directories.splice(0)) { - rmSync(directory, { recursive: true, force: true }) - } -}) - -function preflightStore( - configure: (store: Store) => void, - overrides: Partial = {} -): OrcadMigrationPreflightStore { - const directory = mkdtempSync(join(tmpdir(), 'orcad-export-preflight-')) - directories.push(directory) - const store = createSqliteTestStore(Store, { dataFile: join(directory, 'orca-data.json') }) - configure(store) - return { - getSshTarget: (id) => store.getSshTarget(id), - getSshRemotePtyLeases: (id) => store.getSshRemotePtyLeases(id), - getRepos: () => store.getRepos(), - getFolderWorkspaces: () => store.getFolderWorkspaces(), - getProjectGroups: () => store.getProjectGroups(), - collectOrcadMigrationSourceDormantState: (...args) => - store.collectOrcadMigrationSourceDormantState(...args), - inspectOrcadMigrationUntransferredDependencies: (manifest) => - store.inspectOrcadMigrationUntransferredDependencies(manifest), - ...overrides - } -} - -function withRepo(store: Store): void { - store.addSshTarget(TARGET) - store.addRepo({ - id: 'repo-1', - path: '/srv/app', - displayName: 'App', - badgeColor: '#737373', - addedAt: 1, - kind: 'git', - connectionId: TARGET.id - }) -} - -describe('migration export preflight', () => { - it('drains the catalog the target owns instead of blocking on it', () => { - const result = preflightOrcadMigrationExport(preflightStore(withRepo), TARGET.id) - expect(result.claimable).toBe(true) - expect(result.blockers.map((blocker) => blocker.code)).toEqual([ - 'orcad_migration_direct_ssh_repositories' - ]) - }) - - it('blocks on a live relay terminal, which cannot move', () => { - const lease: SshRemotePtyLease = { - targetId: TARGET.id, - ptyId: 'pty-1', - state: 'attached', - createdAt: 1, - updatedAt: 1 - } - const result = preflightOrcadMigrationExport( - preflightStore(withRepo, { getSshRemotePtyLeases: () => [lease] }), - TARGET.id - ) - expect(result.claimable).toBe(false) - expect(result.blockers.map((blocker) => blocker.code)).toContain( - 'orcad_migration_direct_ssh_terminal_leases' - ) - }) - - it('blocks on dependent state the manifest cannot carry', () => { - const result = preflightOrcadMigrationExport( - preflightStore(withRepo, { - inspectOrcadMigrationUntransferredDependencies: () => ({ - totalCount: 2, - counts: { - automation: 2, - 'automation-run': 0, - 'mobile-tab-selection': 0, - 'retired-worktree-name': 0, - 'saved-port-forward': 0, - 'sparse-preset': 0, - 'terminal-lease': 0, - 'terminal-recovery': 0, - 'ui-routing': 0, - 'workspace-lineage': 0, - 'workspace-session': 0, - 'worktree-lineage': 0, - 'worktree-metadata': 0 - } - }) - }), - TARGET.id - ) - expect(result.claimable).toBe(false) - expect(result.blockers).toContainEqual({ - code: 'orcad_migration_dependent_state', - category: 'client-owned-state', - dependencies: [{ kind: 'automation', count: 2 }] - }) - }) - - it('treats a census the store could not take as unverifiable, never empty', () => { - const result = preflightOrcadMigrationExport( - preflightStore(withRepo, { - inspectOrcadMigrationUntransferredDependencies: () => { - throw new Error('state unreadable') - } - }), - TARGET.id - ) - expect(result.claimable).toBe(false) - expect(result.blockers.map((blocker) => blocker.code)).toContain( - 'orcad_migration_dependency_unverifiable' - ) - }) - - it('refuses a target another runtime owns, and passes the owner its own claim', () => { - const owned = preflightStore((store) => - store.addSshTarget({ ...TARGET, owner: createManagedOrcadSshOwner('env-1') }) - ) - expect(preflightOrcadMigrationExport(owned, TARGET.id).claimable).toBe(false) - expect(preflightOrcadMigrationExport(owned, TARGET.id, 'env-1').claimable).toBe(true) - }) - - it('reports an unknown target', () => { - expect( - preflightOrcadMigrationExport( - preflightStore(() => {}), - 'missing' - ) - ).toMatchObject({ - claimable: false, - blockers: [{ code: 'orcad_migration_target_not_found' }] - }) - }) -}) diff --git a/src/main/ssh/ssh-target-orcad-preflight.ts b/src/main/ssh/ssh-target-orcad-preflight.ts deleted file mode 100644 index 14a2bce0270b..000000000000 --- a/src/main/ssh/ssh-target-orcad-preflight.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Can this relay-hosted SSH target's state be exported to a managed orcad? - * - * Unlike claiming an empty target, export carries the target's repositories, folder - * workspaces and the dormant state the manifest can represent, so those drain rather than block. - * What blocks is what cannot move: another owner, live terminal leases, and dependent state the - * manifest cannot carry. Read-only: building the manifest here exports nothing. - */ -import type { Store } from '../persistence' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { - OrcadMigrationBlocker, - OrcadMigrationPreflight -} from '../../shared/orcad-migration-preflight' -import { - createOrcadMigrationManifest, - type OrcadMigrationExportStore -} from './orcad-migration-manifest-export' -import { collectTargetCatalogBlockers } from './ssh-target-orcad-claims' -import { collectUntransferredDependentBlockers } from './ssh-target-orcad-dependents' - -export type OrcadMigrationPreflightStore = OrcadMigrationExportStore & - Pick< - Store, - 'getSshTarget' | 'getSshRemotePtyLeases' | 'inspectOrcadMigrationUntransferredDependencies' - > - -export function preflightOrcadMigrationExport( - store: OrcadMigrationPreflightStore, - targetId: string, - environmentId?: string -): OrcadMigrationPreflight { - const target = store.getSshTarget(targetId) - if (!target) { - return { - targetId, - targetLabel: null, - claimable: false, - blockers: [{ code: 'orcad_migration_target_not_found', category: 'registration' }] - } - } - if (environmentId && getManagedOrcadOwnerEnvironmentId(target.owner) === environmentId) { - return { targetId, targetLabel: target.label, claimable: true, blockers: [] } - } - const blockers: OrcadMigrationBlocker[] = [...collectTargetCatalogBlockers(store, target)] - const terminalLeases = store - .getSshRemotePtyLeases(targetId) - .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') - .map(({ ptyId, worktreeId, tabId, leafId, state, updatedAt }) => ({ - ptyId, - worktreeId, - tabId, - leafId, - state, - updatedAt - })) - if (terminalLeases.length > 0) { - // A relay PTY cannot move to orcad; its work must finish first. - blockers.push({ - code: 'orcad_migration_direct_ssh_terminal_leases', - category: 'live-or-unverifiable', - terminalLeases - }) - } - blockers.push( - ...collectUntransferredDependentBlockers(store, createOrcadMigrationManifest(store, target)) - ) - return { - targetId, - targetLabel: target.label, - // Exported catalog rows and saved port forwards (which stay with the source) do not block. - claimable: blockers.every( - (blocker) => - blocker.category === 'drainable-static-state' || - blocker.code === 'orcad_migration_saved_port_forwards' - ), - blockers - } -} diff --git a/src/main/terminal-scrollback-snapshot-async-migration.ts b/src/main/terminal-scrollback-snapshot-async-migration.ts index 6916c8de17ab..1768c0a20a1a 100644 --- a/src/main/terminal-scrollback-snapshot-async-migration.ts +++ b/src/main/terminal-scrollback-snapshot-async-migration.ts @@ -40,8 +40,7 @@ export async function migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( export async function deleteRemovedTerminalScrollbackSnapshotsAsync( prior: WorkspaceSessionState | undefined, next: WorkspaceSessionState, - storage?: TerminalScrollbackSnapshotStorage, - retainedRefs: ReadonlySet = new Set() + storage?: TerminalScrollbackSnapshotStorage ): Promise { if (!prior) { return @@ -49,7 +48,7 @@ export async function deleteRemovedTerminalScrollbackSnapshotsAsync( const nextRefs = collectTerminalScrollbackSnapshotRefs(next) await Promise.all( [...collectTerminalScrollbackSnapshotRefs(prior)] - .filter((ref) => !nextRefs.has(ref) && !retainedRefs.has(ref)) + .filter((ref) => !nextRefs.has(ref)) .map((ref) => deleteTerminalScrollbackSnapshot(ref, storage)) ) } diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts index 352edb5ebb4b..70d4be18002a 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -3,7 +3,6 @@ import { closeSync, mkdirSync, openSync, - readFileSync, readSync, renameSync, rmSync, @@ -35,9 +34,7 @@ export function getProfileTerminalScrollbackSnapshotRoot(dataFile: string): stri return join(dirname(dataFile), SNAPSHOT_DIR_NAME) } -export function getTerminalScrollbackSnapshotRoot( - storage?: TerminalScrollbackSnapshotStorage -): string { +function getSnapshotRoot(storage?: TerminalScrollbackSnapshotStorage): string { return storage?.snapshotRoot ?? getLegacySnapshotRoot() } @@ -54,7 +51,7 @@ function snapshotPath(ref: string, snapshotRoot: string): string | null { } function snapshotReadPaths(ref: string, storage?: TerminalScrollbackSnapshotStorage): string[] { - const primaryRoot = getTerminalScrollbackSnapshotRoot(storage) + const primaryRoot = getSnapshotRoot(storage) const primaryPath = snapshotPath(ref, primaryRoot) if (!primaryPath) { return [] @@ -67,32 +64,6 @@ function snapshotReadPaths(ref: string, storage?: TerminalScrollbackSnapshotStor return fallbackPath ? [primaryPath, fallbackPath] : [primaryPath] } -export function getTerminalScrollbackSnapshotPath( - ref: string, - storage?: TerminalScrollbackSnapshotStorage -): string | null { - return snapshotPath(ref, getTerminalScrollbackSnapshotRoot(storage)) -} - -/** The stored bytes for `ref`, bounded by the store limit; `null` when absent or out of bounds. */ -export function readTerminalScrollbackStoredBytesSync( - ref: string, - storage?: TerminalScrollbackSnapshotStorage -): Buffer | null { - for (const path of snapshotReadPaths(ref, storage)) { - try { - const size = statSync(path).size - if (size <= 0 || size > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) { - return null - } - return readFileSync(path) - } catch { - // Try the profile fallback when the primary snapshot is absent. - } - } - return null -} - function trailingUtf8Bytes(value: string, maxBytes: number): Buffer { const bytes = Buffer.from(value, 'utf-8') if (bytes.length <= maxBytes) { @@ -135,7 +106,7 @@ export function writeTerminalScrollbackSnapshotSync(args: { return null } const ref = makeTerminalScrollbackSnapshotRef(args.tabId, args.leafId) - const snapshotRoot = getTerminalScrollbackSnapshotRoot(args.storage) + const snapshotRoot = getSnapshotRoot(args.storage) const path = snapshotPath(ref, snapshotRoot) if (!path) { return null @@ -173,7 +144,7 @@ export async function writeTerminalScrollbackSnapshot(args: { return null } const ref = makeTerminalScrollbackSnapshotRef(args.tabId, args.leafId) - const snapshotRoot = getTerminalScrollbackSnapshotRoot(args.storage) + const snapshotRoot = getSnapshotRoot(args.storage) const path = snapshotPath(ref, snapshotRoot) if (!path) { return null diff --git a/src/main/worktree-retirement-namespace.ts b/src/main/worktree-retirement-namespace.ts index 7f45ed72e921..11abc8a7c1b8 100644 --- a/src/main/worktree-retirement-namespace.ts +++ b/src/main/worktree-retirement-namespace.ts @@ -50,7 +50,7 @@ export function retirementNamespaceKey(hostIdentity: string, probePath: string): /** Rewrites a key's host identity, keeping its workspace-path half. Returns null when the key is * not under `fromIdentity` or the swap is a no-op. */ -export function swapRetirementNamespaceHost( +function swapRetirementNamespaceHost( namespaceKey: string, fromIdentity: string, toIdentity: string diff --git a/src/shared/workspace-session-terminal-buffers.test.ts b/src/shared/workspace-session-terminal-buffers.test.ts index 6c77d18271d3..5eeaf5c98947 100644 --- a/src/shared/workspace-session-terminal-buffers.test.ts +++ b/src/shared/workspace-session-terminal-buffers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession } from './constants' +import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' import type { WorkspaceSessionState } from './workspace-session-state-types' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from './terminal-scrollback-limits' import { getUtf8ByteLength } from './utf8-byte-limits' @@ -150,40 +150,6 @@ describe('pruneLocalTerminalScrollbackBuffers', () => { }) }) - it('keeps a dormant tab buffer, which has no live PTY to replay from', () => { - const session = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - 'local-repo::/w': [ - { - id: 'dormant-tab', - ptyId: null, - worktreeId: 'local-repo::/w', - title: 'Shell', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'dormant-tab': { - root: null, - activeLeafId: null, - expandedLeafId: null, - buffersByLeafId: { 'pane:1': 'dormant output' } - } - } - } - const result = pruneLocalTerminalScrollbackBuffers(session, [ - { id: 'local-repo', connectionId: null } - ]) - expect(result.terminalLayoutsByTabId['dormant-tab'].buffersByLeafId).toEqual({ - 'pane:1': 'dormant output' - }) - }) - it('drops scrollback for explicitly local execution hosts', () => { const result = pruneLocalTerminalScrollbackBuffers(makeRuntimeSession(), [ { diff --git a/src/shared/workspace-session-terminal-buffers.ts b/src/shared/workspace-session-terminal-buffers.ts index c7af0fc5b6e9..fa4dd0e095b6 100644 --- a/src/shared/workspace-session-terminal-buffers.ts +++ b/src/shared/workspace-session-terminal-buffers.ts @@ -91,24 +91,19 @@ export function pruneLocalTerminalScrollbackBuffers( repos: readonly RepoConnection[] ): WorkspaceSessionState { let repoById: Map | null = null - let tabById: Map | null = null + let worktreeIdByTabId: Map | null = null const tabsByWorktree = session.tabsByWorktree ?? {} const preservesScrollback = (tabId: string): boolean => { repoById ??= new Map(repos.map((repo) => [repo.id, repo] as const)) - if (!tabById) { - tabById = new Map() + if (!worktreeIdByTabId) { + worktreeIdByTabId = new Map() for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { for (const tab of tabs) { - tabById.set(tab.id, { worktreeId, ptyId: tab.ptyId }) + worktreeIdByTabId.set(tab.id, worktreeId) } } } - const tab = tabById.get(tabId) - // A dormant tab has no live PTY to replay from, so its saved buffer is the only copy. - return ( - tab?.ptyId === null || - shouldPreserveTerminalScrollbackBuffersForRepoMap(tab?.worktreeId, repoById) - ) + return shouldPreserveTerminalScrollbackBuffersForRepoMap(worktreeIdByTabId.get(tabId), repoById) } const terminalLayoutsByTabIdForRead = session.terminalLayoutsByTabId ?? {} From f3eb614ad228a7c7753a6b00c2997a482a259ac1 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:01 -0700 Subject: [PATCH 02/26] Revert "feat(ssh): update, roll back, recover and stop a managed orcad server (#16741 T6-5 follow-up) (#24463)" This reverts commit 38c2d1dcb9dc079bba6f59bdaa179d6af0584f09. --- .../daemon/daemon-provider-census.test.ts | 12 - src/main/daemon/daemon-provider-state.ts | 15 +- .../ipc/orcad-runtime-lifecycle-handlers.ts | 2 +- ...orcad-runtime-maintenance-handlers.test.ts | 87 ------- .../ipc/orcad-runtime-maintenance-handlers.ts | 86 ------- ...ntime-environment-connectivity-handlers.ts | 20 +- .../runtime-environment-handler-channels.ts | 5 - .../runtime-environment-removal-cleanup.ts | 27 --- .../ipc/runtime-environments-pairing.test.ts | 10 - src/main/ipc/runtime-environments.ts | 6 - src/main/orcad/orcad-terminal-census.test.ts | 51 ----- src/main/orcad/orcad-terminal-census.ts | 39 ---- src/main/runtime/rpc/methods/index.ts | 2 - .../rpc/methods/orcad-terminal-census.ts | 18 -- .../orcad-managed-lifecycle-test-fixture.ts | 103 --------- src/main/ssh/orcad-managed-runtime-context.ts | 14 -- .../ssh/orcad-managed-update-deferrals.ts | 22 -- .../ssh/orcad-runtime-decommission.test.ts | 216 ------------------ src/main/ssh/orcad-runtime-decommission.ts | 205 ----------------- src/main/ssh/orcad-runtime-deployment.test.ts | 11 +- src/main/ssh/orcad-runtime-deployment.ts | 14 +- src/main/ssh/orcad-runtime-lifecycle.ts | 6 - .../ssh/orcad-runtime-maintenance.test.ts | 197 ---------------- src/main/ssh/orcad-runtime-maintenance.ts | 215 ----------------- src/main/ssh/orcad-runtime-status.ts | 6 +- .../ssh/orcad-terminal-census-client.test.ts | 101 -------- src/main/ssh/orcad-terminal-census-client.ts | 82 ------- src/shared/orcad-managed-runtime.ts | 61 +---- src/shared/orcad-terminal-census.ts | 8 - src/shared/protocol-version.ts | 5 - .../rpc-params-catalog.generated.ts | 2 - ...me-environment-managed-orcad-store.test.ts | 35 +-- ...runtime-environment-managed-orcad-store.ts | 85 +------ 33 files changed, 45 insertions(+), 1723 deletions(-) delete mode 100644 src/main/ipc/orcad-runtime-maintenance-handlers.test.ts delete mode 100644 src/main/ipc/orcad-runtime-maintenance-handlers.ts delete mode 100644 src/main/ipc/runtime-environment-removal-cleanup.ts delete mode 100644 src/main/orcad/orcad-terminal-census.test.ts delete mode 100644 src/main/orcad/orcad-terminal-census.ts delete mode 100644 src/main/runtime/rpc/methods/orcad-terminal-census.ts delete mode 100644 src/main/ssh/orcad-managed-lifecycle-test-fixture.ts delete mode 100644 src/main/ssh/orcad-managed-update-deferrals.ts delete mode 100644 src/main/ssh/orcad-runtime-decommission.test.ts delete mode 100644 src/main/ssh/orcad-runtime-decommission.ts delete mode 100644 src/main/ssh/orcad-runtime-maintenance.test.ts delete mode 100644 src/main/ssh/orcad-runtime-maintenance.ts delete mode 100644 src/main/ssh/orcad-terminal-census-client.test.ts delete mode 100644 src/main/ssh/orcad-terminal-census-client.ts diff --git a/src/main/daemon/daemon-provider-census.test.ts b/src/main/daemon/daemon-provider-census.test.ts index 30aade980fdc..2b0d6f88ac89 100644 --- a/src/main/daemon/daemon-provider-census.test.ts +++ b/src/main/daemon/daemon-provider-census.test.ts @@ -7,7 +7,6 @@ import { createAdapter } from './daemon-pty-router-test-fixture' import { disconnectDaemon, listLiveDaemonSessions, - listLiveDaemonSessionsWithProtocol, replaceDaemonProvider, requestIdleDaemonRetirement } from './daemon-provider-state' @@ -31,17 +30,6 @@ it('reads a census with an unanswered generation as unverifiable', async () => { await expect(listLiveDaemonSessions()).resolves.toBeNull() }) -it('labels each live session with the protocol of the generation that owns it', async () => { - const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', ['live-2'], undefined, PROTOCOL_VERSION - 1) - replaceDaemonProvider(new DaemonPtyRouter({ current, legacy: [legacy] })) - - await expect(listLiveDaemonSessionsWithProtocol()).resolves.toEqual([ - { sessionId: 'live-1', isAlive: true, protocolVersion: PROTOCOL_VERSION }, - { sessionId: 'live-2', isAlive: true, protocolVersion: PROTOCOL_VERSION - 1 } - ]) -}) - it('lists every generation when each one answers', async () => { const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION) const legacy = createAdapter('legacy', ['live-2'], undefined, PROTOCOL_VERSION) diff --git a/src/main/daemon/daemon-provider-state.ts b/src/main/daemon/daemon-provider-state.ts index f54d886206db..f32b8a3c204e 100644 --- a/src/main/daemon/daemon-provider-state.ts +++ b/src/main/daemon/daemon-provider-state.ts @@ -11,7 +11,7 @@ import { getMacDaemonTccAttributionHealth, type MacDaemonTccAttributionHealth } from './daemon-tcc-attribution' -import { PROTOCOL_VERSION, type DaemonSessionInfo, type SessionInfo } from './types' +import { PROTOCOL_VERSION, type SessionInfo } from './types' import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state' let spawner: DaemonSpawner | null = null @@ -147,12 +147,6 @@ export async function listLiveDaemonPtyIds(): Promise { /** Returns null unless every daemon generation supplied an authoritative session inventory. */ export async function listLiveDaemonSessions(): Promise { - const sessions = await listLiveDaemonSessionsWithProtocol() - return sessions?.map(({ protocolVersion: _protocolVersion, ...session }) => session) ?? null -} - -/** Like listLiveDaemonSessions, with the protocol of the daemon generation owning each session. */ -export async function listLiveDaemonSessionsWithProtocol(): Promise { if (!adapter) { return null } @@ -161,12 +155,7 @@ export async function listLiveDaemonSessionsWithProtocol(): Promise - (await daemonAdapter.listSessions()).map((session) => ({ - ...session, - protocolVersion: daemonAdapter.protocolVersion - })) - ) + adapters.map((daemonAdapter) => daemonAdapter.listSessions()) ) if (inventories.some((inventory) => inventory.status === 'rejected')) { return null diff --git a/src/main/ipc/orcad-runtime-lifecycle-handlers.ts b/src/main/ipc/orcad-runtime-lifecycle-handlers.ts index 4d6e23833b19..35801d8de779 100644 --- a/src/main/ipc/orcad-runtime-lifecycle-handlers.ts +++ b/src/main/ipc/orcad-runtime-lifecycle-handlers.ts @@ -29,7 +29,7 @@ export function registerOrcadRuntimeLifecycleHandlers(options: { ) } -export function requiredString(value: unknown, label: string): string { +function requiredString(value: unknown, label: string): string { if (typeof value !== 'string' || !value.trim()) { throw new Error(`${label} is required.`) } diff --git a/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts b/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts deleted file mode 100644 index ad87a398cc77..000000000000 --- a/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - update: vi.fn(), - rollback: vi.fn(), - recover: vi.fn(), - stop: vi.fn(), - cancel: vi.fn(), - retire: vi.fn() -})) - -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/orcad-runtime-lifecycle', () => ({ - updateManagedOrcadEnvironment: mocks.update, - rollbackManagedOrcadEnvironment: mocks.rollback, - recoverManagedOrcadEnvironment: mocks.recover, - stopManagedOrcadEnvironment: mocks.stop, - cancelManagedOrcadStop: mocks.cancel -})) -vi.mock('./runtime-environment-removal-cleanup', () => ({ - retireRemovedRuntimeEnvironment: mocks.retire -})) - -const { registerOrcadRuntimeMaintenanceHandlers } = - await import('./orcad-runtime-maintenance-handlers') - -const invalidateTransport = vi.fn() - -function handler(channel: string): (_event: unknown, args: unknown) => Promise { - const registration = mocks.handle.mock.calls.find(([name]) => name === channel) - if (!registration) { - throw new Error(`${channel} handler was not registered`) - } - return registration[1] -} - -describe('managed orcad maintenance IPC', () => { - beforeEach(() => { - vi.clearAllMocks() - registerOrcadRuntimeMaintenanceHandlers({ - getUserDataPath: () => '/profile', - getActiveEnvironmentId: () => 'active-environment', - invalidateTransport - }) - }) - - it('reconnects after an update restarts orcad, but not after a deferral', async () => { - mocks.update.mockResolvedValueOnce({ outcome: 'deferred', code: 'busy' }) - await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: 'yes' }) - expect(mocks.update).toHaveBeenCalledWith('/profile', { selector: 'Managed', force: false }) - expect(invalidateTransport).not.toHaveBeenCalled() - mocks.update.mockResolvedValueOnce({ outcome: 'updated', environment: { id: 'e-1' } }) - await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: true }) - expect(invalidateTransport).toHaveBeenCalledWith('e-1') - }) - - it('reconnects after rollback and after recovery restores a serving slot', async () => { - mocks.rollback.mockResolvedValueOnce({ outcome: 'rolled-back', environment: { id: 'e-1' } }) - await handler('runtimeEnvironments:rollbackOrcad')(null, { selector: 'Managed' }) - mocks.recover.mockResolvedValueOnce({ - outcome: 'recovered', - activeVersion: null, - environment: { id: 'e-1' } - }) - await handler('runtimeEnvironments:recoverOrcad')(null, { selector: 'Managed' }) - expect(invalidateTransport).toHaveBeenCalledTimes(1) - }) - - it('stops with the Active Server guard and the shared removal cleanup', async () => { - mocks.stop.mockResolvedValueOnce({ outcome: 'unlinked' }) - await handler('runtimeEnvironments:stopOrcad')(null, { selector: ' Managed ' }) - const [, args, policy] = mocks.stop.mock.calls[0] ?? [] - expect(args).toEqual({ selector: 'Managed' }) - expect(policy.isActiveEnvironment('active-environment')).toBe(true) - expect(policy.isActiveEnvironment('e-1')).toBe(false) - policy.retireLocalState('e-1') - expect(mocks.retire).toHaveBeenCalledWith('e-1', invalidateTransport) - }) - - it('rejects a missing selector before touching SSH', async () => { - await expect(handler('runtimeEnvironments:cancelOrcadStop')(null, {})).rejects.toThrow( - 'Server is required' - ) - expect(mocks.cancel).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/orcad-runtime-maintenance-handlers.ts b/src/main/ipc/orcad-runtime-maintenance-handlers.ts deleted file mode 100644 index 0f16da4de074..000000000000 --- a/src/main/ipc/orcad-runtime-maintenance-handlers.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { ipcMain } from 'electron' -import type { - OrcadManagedCancelStopResult, - OrcadManagedDeployResult, - OrcadManagedRecoveryResult, - OrcadManagedRollbackResult, - OrcadManagedStopResult -} from '../../shared/orcad-managed-runtime' -import { - cancelManagedOrcadStop, - recoverManagedOrcadEnvironment, - rollbackManagedOrcadEnvironment, - stopManagedOrcadEnvironment, - updateManagedOrcadEnvironment -} from '../ssh/orcad-runtime-lifecycle' -import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup' -import { requiredString } from './orcad-runtime-lifecycle-handlers' - -export function registerOrcadRuntimeMaintenanceHandlers(options: { - getUserDataPath: () => string - getActiveEnvironmentId: () => string | null | undefined - invalidateTransport: (environmentId: string) => Promise | void -}): void { - ipcMain.handle( - 'runtimeEnvironments:updateOrcad', - async ( - _event, - args: { selector: string; force?: boolean } - ): Promise => { - const result = await updateManagedOrcadEnvironment(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server'), - force: args?.force === true - }) - // Why: a restarted orcad drops the old connection; reconnect on the new one. - if (result.outcome === 'updated') { - await options.invalidateTransport(result.environment.id) - } - return result - } - ) - ipcMain.handle( - 'runtimeEnvironments:rollbackOrcad', - async (_event, args: { selector: string }): Promise => { - const result = await rollbackManagedOrcadEnvironment(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server') - }) - if (result.outcome === 'rolled-back') { - await options.invalidateTransport(result.environment.id) - } - return result - } - ) - ipcMain.handle( - 'runtimeEnvironments:recoverOrcad', - async (_event, args: { selector: string }): Promise => { - const result = await recoverManagedOrcadEnvironment(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server') - }) - if (result.outcome === 'recovered' && result.activeVersion) { - await options.invalidateTransport(result.environment.id) - } - return result - } - ) - ipcMain.handle( - 'runtimeEnvironments:stopOrcad', - async (_event, args: { selector: string }): Promise => - stopManagedOrcadEnvironment( - options.getUserDataPath(), - { selector: requiredString(args?.selector, 'Server') }, - { - isActiveEnvironment: (environmentId) => - options.getActiveEnvironmentId() === environmentId, - retireLocalState: (environmentId) => - retireRemovedRuntimeEnvironment(environmentId, options.invalidateTransport) - } - ) - ) - ipcMain.handle( - 'runtimeEnvironments:cancelOrcadStop', - async (_event, args: { selector: string }): Promise => - cancelManagedOrcadStop(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server') - }) - ) -} diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 6513c0756b87..84d2754d556a 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -14,8 +14,10 @@ import { RuntimeRpcCallQueueOverloadError } from '../../shared/runtime-rpc-call- import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../shared/runtime-types' import type { Store } from '../persistence' -import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup' +import { clearBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-runtime' +import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-retirement' import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification' +import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' import { closeRemoteRuntimeRequestConnection, getRuntimeEnvironmentStatusOwner, @@ -104,8 +106,22 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ throw new Error('Choose another Active Server in Advanced before removing this server.') } const removed = removeEnvironment(getUserDataPath(), args.selector) - void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport) + clearRuntimeEnvironmentCapabilityEvidence(removed.id) + clearRuntimeEnvironmentManualDisconnect(removed.id) + const retiring = Promise.resolve(invalidateTransport(removed.id)) closeLegacySelectorTransport(args.selector, removed.id) + // Why: removal is an explicit lifecycle decision, so its client-hosted browser storage goes + // too -- but only once the client host releases its partitions, or every one refuses as live. + void retireBrowserRoutePartitionStorageForEnvironment({ + environmentId: removed.id, + whenClientHostClosed: retiring, + clearStorage: clearBrowserRoutePartitionStorageForEnvironment, + onError: (error) => { + console.warn('[runtime-environments] browser partition storage clear failed:', error) + } + }).catch((error) => { + console.warn('[runtime-environments] browser partition storage clear failed:', error) + }) return { removed: redactRuntimeEnvironment(removed) } } ) diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts index 3af5e61a64c6..de80ea0b1785 100644 --- a/src/main/ipc/runtime-environment-handler-channels.ts +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -17,11 +17,6 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:unlinkSshAccess', 'runtimeEnvironments:deployOrcad', 'runtimeEnvironments:getOrcadStatus', - 'runtimeEnvironments:updateOrcad', - 'runtimeEnvironments:rollbackOrcad', - 'runtimeEnvironments:recoverOrcad', - 'runtimeEnvironments:stopOrcad', - 'runtimeEnvironments:cancelOrcadStop', 'runtimeEnvironments:createOrcadSshHost', 'runtimeEnvironments:resumeOrcadSshHost', 'runtimeEnvironments:listPendingOrcadSshProvisioning' diff --git a/src/main/ipc/runtime-environment-removal-cleanup.ts b/src/main/ipc/runtime-environment-removal-cleanup.ts deleted file mode 100644 index c4141c0e00e8..000000000000 --- a/src/main/ipc/runtime-environment-removal-cleanup.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { clearBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-runtime' -import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-retirement' -import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' -import { clearRuntimeEnvironmentManualDisconnect } from './runtime-environment-manual-disconnect' - -/** Retires a removed server's client-side state; resolves once its transport is invalidated. */ -export function retireRemovedRuntimeEnvironment( - environmentId: string, - invalidateTransport: (environmentId: string) => Promise | void -): Promise { - clearRuntimeEnvironmentCapabilityEvidence(environmentId) - clearRuntimeEnvironmentManualDisconnect(environmentId) - const retiring = Promise.resolve(invalidateTransport(environmentId)) - // Why: removal is an explicit lifecycle decision, so its client-hosted browser storage goes - // too -- but only once the client host releases its partitions, or every one refuses as live. - void retireBrowserRoutePartitionStorageForEnvironment({ - environmentId, - whenClientHostClosed: retiring, - clearStorage: clearBrowserRoutePartitionStorageForEnvironment, - onError: (error) => { - console.warn('[runtime-environments] browser partition storage clear failed:', error) - } - }).catch((error) => { - console.warn('[runtime-environments] browser partition storage clear failed:', error) - }) - return retiring -} diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index b1dc936acf8e..ba817f3eb82f 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -159,11 +159,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:listPendingOrcadSshProvisioning', 'runtimeEnvironments:deployOrcad', 'runtimeEnvironments:getOrcadStatus', - 'runtimeEnvironments:updateOrcad', - 'runtimeEnvironments:rollbackOrcad', - 'runtimeEnvironments:recoverOrcad', - 'runtimeEnvironments:stopOrcad', - 'runtimeEnvironments:cancelOrcadStop', 'runtimeEnvironments:subscribe', 'runtimeEnvironments:unsubscribe' ]) @@ -194,11 +189,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:unlinkSshAccess', 'runtimeEnvironments:deployOrcad', 'runtimeEnvironments:getOrcadStatus', - 'runtimeEnvironments:updateOrcad', - 'runtimeEnvironments:rollbackOrcad', - 'runtimeEnvironments:recoverOrcad', - 'runtimeEnvironments:stopOrcad', - 'runtimeEnvironments:cancelOrcadStop', 'runtimeEnvironments:createOrcadSshHost', 'runtimeEnvironments:resumeOrcadSshHost', 'runtimeEnvironments:listPendingOrcadSshProvisioning', diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 3117a81a0677..7e50f4b8f71d 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -19,7 +19,6 @@ import { } from './runtime-environment-subscriptions' import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels' import { registerOrcadRuntimeLifecycleHandlers } from './orcad-runtime-lifecycle-handlers' -import { registerOrcadRuntimeMaintenanceHandlers } from './orcad-runtime-maintenance-handlers' import { registerRuntimeSshAccessHandlers } from './runtime-ssh-access-handlers' import { retirePairedRuntimeBrowserClientHostEnvironment } from '../browser/paired-runtime-browser-client-host-runtime' import { registerRuntimeEnvironmentBrowserClientHostHandler } from './runtime-environment-browser-client-host-handler' @@ -75,10 +74,5 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { invalidateTransport: invalidateRuntimeEnvironmentTransport }) registerOrcadRuntimeLifecycleHandlers({ getUserDataPath }) - registerOrcadRuntimeMaintenanceHandlers({ - getUserDataPath, - getActiveEnvironmentId: () => store.getSettings().activeRuntimeEnvironmentId, - invalidateTransport: invalidateRuntimeEnvironmentTransport - }) registerRuntimeEnvironmentSubscriptions(getUserDataPath) } diff --git a/src/main/orcad/orcad-terminal-census.test.ts b/src/main/orcad/orcad-terminal-census.test.ts deleted file mode 100644 index e8067f8a2b18..000000000000 --- a/src/main/orcad/orcad-terminal-census.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { DaemonSessionInfo } from '../daemon/types' -import { collectOrcadTerminalCensus } from './orcad-terminal-census' - -function session(createdAt: number, protocolVersion = 7): DaemonSessionInfo { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only createdAt and protocolVersion. - return { sessionId: `s-${createdAt}`, createdAt, protocolVersion } as DaemonSessionInfo -} - -describe('orcad terminal census', () => { - it('counts live sessions, those since activation, and their single owning protocol', async () => { - await expect( - collectOrcadTerminalCensus(100, async () => [session(50), session(100), session(150)]) - ).resolves.toEqual({ liveSessions: 3, startedSinceActivation: 2, daemonProtocolVersion: 7 }) - }) - - it('reports zero, not unknown, for an answered empty daemon', async () => { - await expect(collectOrcadTerminalCensus(100, async () => [])).resolves.toEqual({ - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: null - }) - }) - - it('leaves the protocol unknown when sessions span daemon generations', async () => { - const census = await collectOrcadTerminalCensus(0, async () => [session(1, 7), session(2, 6)]) - expect(census.daemonProtocolVersion).toBeNull() - expect(census.liveSessions).toBe(2) - }) - - it('leaves the since-activation count unknown when a session has no creation time', async () => { - const census = await collectOrcadTerminalCensus(0, async () => [session(0)]) - expect(census).toMatchObject({ liveSessions: 1, startedSinceActivation: null }) - }) - - it.each([ - ['no inventory', async () => null], - [ - 'a failed inventory', - async () => { - throw new Error('daemon unreachable') - } - ] - ])('reads %s as unverifiable, never as zero', async (_label, list) => { - await expect(collectOrcadTerminalCensus(0, list)).resolves.toEqual({ - liveSessions: null, - startedSinceActivation: null, - daemonProtocolVersion: null - }) - }) -}) diff --git a/src/main/orcad/orcad-terminal-census.ts b/src/main/orcad/orcad-terminal-census.ts deleted file mode 100644 index 63fd96e72bd6..000000000000 --- a/src/main/orcad/orcad-terminal-census.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** The terminal census a managed orcad reports to the client planning an update or stop. */ -import { listLiveDaemonSessionsWithProtocol } from '../daemon/daemon-provider-state' -import type { DaemonSessionInfo } from '../daemon/types' -import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' - -const UNVERIFIABLE: OrcadTerminalCensus = { - liveSessions: null, - startedSinceActivation: null, - daemonProtocolVersion: null -} - -export async function collectOrcadTerminalCensus( - activatedAt: number, - listSessions: () => Promise = listLiveDaemonSessionsWithProtocol -): Promise { - let sessions: DaemonSessionInfo[] | null - try { - sessions = await listSessions() - } catch { - sessions = null - } - if (!sessions) { - return UNVERIFIABLE - } - const timestampsKnown = sessions.every( - (session) => Number.isFinite(session.createdAt) && session.createdAt > 0 - ) - const protocols = new Set(sessions.map((session) => session.protocolVersion)) - const [protocol] = protocols - return { - liveSessions: sessions.length, - startedSinceActivation: timestampsKnown - ? sessions.filter((session) => session.createdAt >= activatedAt).length - : null, - // Why one protocol only: sessions split across daemon generations have no single owner to - // check an incoming build against, so that reads as unverifiable. - daemonProtocolVersion: protocols.size === 1 && protocol !== undefined ? protocol : null - } -} diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 762d61cf3aad..cbf6e0d8e0c4 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -1,5 +1,4 @@ import { STATUS_METHODS } from './status' -import { ORCAD_TERMINAL_CENSUS_METHODS } from './orcad-terminal-census' import { AI_VAULT_METHODS } from './ai-vault' import { AUTOMATION_METHODS } from './automations' import { REPO_METHODS } from './repo' @@ -55,7 +54,6 @@ import { AGENT_LAUNCH_METHODS } from './agent-launch' // auditing the security boundary or wiring new CLI commands. export const ALL_RPC_METHODS = [ ...STATUS_METHODS, - ...ORCAD_TERMINAL_CENSUS_METHODS, ...AGENT_HOOK_METHODS, ...AI_VAULT_METHODS, ...ARTIFACT_METHODS, diff --git a/src/main/runtime/rpc/methods/orcad-terminal-census.ts b/src/main/runtime/rpc/methods/orcad-terminal-census.ts deleted file mode 100644 index a35156c721b8..000000000000 --- a/src/main/runtime/rpc/methods/orcad-terminal-census.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineMethod } from '../core' -import { - ORCAD_TERMINAL_CENSUS_METHOD, - OrcadTerminalCensusParamsSchema -} from '../../../../shared/orcad-terminal-census' - -export const ORCAD_TERMINAL_CENSUS_METHODS = [ - defineMethod({ - name: ORCAD_TERMINAL_CENSUS_METHOD, - params: OrcadTerminalCensusParamsSchema, - handler: async (params) => { - // Why lazy: the census reaches the daemon modules, whose xterm polyfill defines a global - // `window`; importing them statically would load it into every process with the dispatcher. - const { collectOrcadTerminalCensus } = await import('../../../orcad/orcad-terminal-census') - return collectOrcadTerminalCensus(params.activatedAt) - } - }) -] diff --git a/src/main/ssh/orcad-managed-lifecycle-test-fixture.ts b/src/main/ssh/orcad-managed-lifecycle-test-fixture.ts deleted file mode 100644 index 37d27cd3c9ae..000000000000 --- a/src/main/ssh/orcad-managed-lifecycle-test-fixture.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' -import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' -import type { SshTarget } from '../../shared/ssh-types' -import type { ServeReadiness } from '../server/serve-readiness' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' - -export const MANAGED_VERSION = '0.2.0+abc' -export const MANAGED_PREVIOUS_VERSION = '0.1.0+def' -export const MANAGED_LOCAL_PORT = 46_768 - -/** A managed server registered in a temp profile, with its claimed SSH target. */ -export function createManagedLifecycleHarness() { - const userDataPath = mkdtempSync(join(tmpdir(), 'orcad-managed-lifecycle-')) - let target: SshTarget = { - id: 'ssh-1', - label: 'Builder', - host: 'builder', - port: 22, - username: 'dev', - generation: 4, - owner: createManagedOrcadSshOwner('environment-1') - } - const environment = addManagedOrcadEnvironment(userDataPath, { - id: 'environment-1', - name: 'Managed', - pairingCode: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint: `ws://127.0.0.1:${MANAGED_LOCAL_PORT}/`, - deviceToken: 'device-token', - publicKeyB64: 'public-key' - }), - orcadDeployment: { - sshTargetId: 'ssh-1', - sshTargetGeneration: 4, - localPort: MANAGED_LOCAL_PORT, - remotePort: 6_768 - } - }) - const flushes: number[] = [] - const claims = new SshTargetOrcadClaims({ - ...emptyDependentStateStore(), - allocateSshTargetGeneration: () => 5, - flushPendingOrThrowAsync: async () => { - flushes.push(flushes.length) - }, - getFolderWorkspaces: () => [], - getRepos: () => [], - getSshTarget: (id) => (id === target.id ? target : undefined), - getSshTargets: () => [target], - updateSshTarget: (_id, updates) => (target = { ...target, ...updates }) - }) - const targetStore = { - getTarget: (id: string) => (id === target.id ? target : undefined), - getOrcadRuntimeClaims: () => claims - } - const context = (record: Record = {}) => ({ - activationRecord: { - active: MANAGED_VERSION, - previous: MANAGED_PREVIOUS_VERSION, - activatedAt: '2026-01-01T00:00:00.000Z', - snapshot: null, - ...record - }, - serverTarget: 'linux-x64-glibc', - connection: {}, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/dev', - target, - userDataDir: '/home/dev/.orca' - }) - return { userDataPath, environment, targetStore, context, flushes, current: () => target } -} - -/** Readiness whose pairing offer, once tunneled, matches the harness server's saved one. */ -export function managedReadiness(deviceToken = 'device-token'): ServeReadiness { - const endpoint = 'ws://127.0.0.1:6768' - return { - runtimeId: 'runtime-1', - boundEndpoint: endpoint, - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { - available: true, - url: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint, - deviceToken, - publicKeyB64: 'public-key' - }), - endpoint, - deviceId: 'device-1', - webClientUrl: null, - scope: 'runtime', - qr: null - } - } -} diff --git a/src/main/ssh/orcad-managed-runtime-context.ts b/src/main/ssh/orcad-managed-runtime-context.ts index 37e9c5d9daa4..55a29be73986 100644 --- a/src/main/ssh/orcad-managed-runtime-context.ts +++ b/src/main/ssh/orcad-managed-runtime-context.ts @@ -65,20 +65,6 @@ export async function resolveLinkedOrcadContext( return resolveOrcadRemoteContext(target, connection, signal) } -/** The slot options every remote lifecycle step takes for this managed server. */ -export function managedOrcadSlot(context: OrcadRemoteContext, port: number, signal?: AbortSignal) { - return { - conn: context.connection, - host: context.host, - remoteHome: context.remoteHome, - nodePath: MANAGED_ORCAD_LEGACY_NODE_PATH, - userDataDir: context.userDataDir, - bindHost: ORCAD_BIND_HOST, - port, - signal - } -} - export function managedOrcadInstallDir(context: OrcadRemoteContext, version: string): string { return computeRemoteInstallDir( ORCAD_INSTALL_MODEL, diff --git a/src/main/ssh/orcad-managed-update-deferrals.ts b/src/main/ssh/orcad-managed-update-deferrals.ts deleted file mode 100644 index 1bf0285f2b22..000000000000 --- a/src/main/ssh/orcad-managed-update-deferrals.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** The last update each managed server deferred in this session, so status can report it. */ -import type { OrcadManagedDeferral } from '../../shared/orcad-managed-runtime' - -type RecordedDeferral = OrcadManagedDeferral & { deferredAt: string } - -const deferrals = new Map() - -export function recordManagedOrcadUpdateDeferral( - environmentId: string, - deferral: OrcadManagedDeferral, - now = new Date() -): void { - deferrals.set(environmentId, { ...deferral, deferredAt: now.toISOString() }) -} - -export function clearManagedOrcadUpdateDeferral(environmentId: string): void { - deferrals.delete(environmentId) -} - -export function readManagedOrcadUpdateDeferral(environmentId: string): RecordedDeferral | null { - return deferrals.get(environmentId) ?? null -} diff --git a/src/main/ssh/orcad-runtime-decommission.test.ts b/src/main/ssh/orcad-runtime-decommission.test.ts deleted file mode 100644 index b045fbcefdc5..000000000000 --- a/src/main/ssh/orcad-runtime-decommission.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { existsSync, readFileSync, rmSync } from 'node:fs' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import { getRuntimeEnvironmentSidecarPath } from '../../shared/runtime-environment-sidecar' -import { listEnvironments } from '../../shared/runtime-environment-store' -import { - createManagedLifecycleHarness, - MANAGED_VERSION -} from './orcad-managed-lifecycle-test-fixture' - -const mocks = vi.hoisted(() => { - const state: { store: unknown; transaction: unknown } = { store: null, transaction: null } - return { - state, - resolveContext: vi.fn(), - census: vi.fn(), - decommission: vi.fn(), - recover: vi.fn(), - cancel: vi.fn(), - keepServing: vi.fn(), - closeTunnel: vi.fn(), - ensureTunnel: vi.fn(), - retire: vi.fn() - } -}) - -vi.mock('./ssh-target-registry', () => ({ - getSshConnectionManager: () => ({ connect: async () => ({}) }), - getSshTargetRegistryStore: () => mocks.state.store -})) -vi.mock('./orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext })) -vi.mock('./orcad-terminal-census-client', () => ({ collectManagedTerminalCensus: mocks.census })) -vi.mock('./orcad-remote-stop', () => ({ decommissionRemoteOrcad: mocks.decommission })) -vi.mock('./orcad-activation-recovery', () => ({ recoverInterruptedOrcadActivation: mocks.recover })) -vi.mock('./orcad-activation-transaction-store', () => ({ - readOrcadActivationTransaction: async () => mocks.state.transaction -})) -vi.mock('./orcad-activation-lock', () => ({ - withStaleOrcadActivationRecoveryLock: async ( - _options: unknown, - run: (lock: { retain: () => void }) => Promise - ) => run({ retain: () => {} }) -})) -vi.mock('./orcad-managed-remote-stop', () => ({ cancelRemoteOrcadManagedStop: mocks.cancel })) -vi.mock('./orcad-decommission-recovery', () => ({ reconcileOrcadDecommission: mocks.keepServing })) -vi.mock('./orcad-managed-tunnel', () => ({ - closeOrcadManagedTunnel: mocks.closeTunnel, - ensureOrcadManagedTunnel: mocks.ensureTunnel -})) - -const { cancelManagedOrcadStop, stopManagedOrcadEnvironment } = - await import('./orcad-runtime-lifecycle') - -const idle = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 7 } -let harness: ReturnType - -const stop = (isActive = false) => - stopManagedOrcadEnvironment( - harness.userDataPath, - { selector: 'Managed' }, - { isActiveEnvironment: () => isActive, retireLocalState: mocks.retire } - ) - -function expectStillLinked(): void { - expect(listEnvironments(harness.userDataPath)[0]?.orcadDeployment).toBeDefined() - expect(getManagedOrcadOwnerEnvironmentId(harness.current().owner)).toBe('environment-1') - expect(mocks.retire).not.toHaveBeenCalled() - expect(mocks.closeTunnel).not.toHaveBeenCalled() -} - -beforeEach(() => { - vi.resetAllMocks() - harness = createManagedLifecycleHarness() - mocks.state.store = harness.targetStore - mocks.state.transaction = null - mocks.resolveContext.mockImplementation(async () => harness.context()) - mocks.census.mockResolvedValue(idle) -}) - -afterEach(() => rmSync(harness.userDataPath, { recursive: true, force: true })) - -describe('stopManagedOrcadEnvironment', () => { - it('unlinks the server only after the host proves orcad exited', async () => { - mocks.decommission.mockResolvedValueOnce({ - outcome: 'decommissioned', - version: MANAGED_VERSION, - retirement: 'retired' - }) - await expect(stop()).resolves.toEqual({ - outcome: 'unlinked', - verdict: 'exited', - environmentId: 'environment-1', - sshTargetId: 'ssh-1', - stoppedVersion: MANAGED_VERSION, - retirement: 'retired' - }) - expect(mocks.decommission.mock.calls[0]?.[0]).toMatchObject({ census: idle, port: 6_768 }) - expect(listEnvironments(harness.userDataPath)).toEqual([]) - expect( - readFileSync(getRuntimeEnvironmentSidecarPath(harness.userDataPath), 'utf8') - ).not.toContain('orcadDeployment') - expect(harness.current().owner).toBeUndefined() - expect(harness.flushes).toHaveLength(1) - expect(mocks.retire).toHaveBeenCalledWith('environment-1') - expect(mocks.closeTunnel).toHaveBeenCalledWith('environment-1') - }) - - it.each([ - ['live', 'orcad_decommission_terminals_running'], - ['unverifiable', 'orcad_decommission_census_unavailable'], - ['unverifiable', 'orcad_decommission_stop_unsettled'] - ] as const)('keeps every link when the verdict is %s (%s)', async (verdict, code) => { - mocks.decommission.mockResolvedValueOnce({ outcome: 'refused', verdict, code, reason: 'no' }) - await expect(stop()).resolves.toMatchObject({ outcome: 'refused', verdict, code }) - expectStillLinked() - expect(existsSync(getRuntimeEnvironmentSidecarPath(harness.userDataPath))).toBe(true) - }) - - it('refuses the Active Server before contacting the host', async () => { - await expect(stop(true)).resolves.toMatchObject({ code: 'orcad_stop_active_environment' }) - expect(mocks.resolveContext).not.toHaveBeenCalled() - expectStillLinked() - }) - - it('finishes an interrupted stop from its journal and unlinks only on proven exit', async () => { - mocks.state.transaction = { operation: 'decommission', activeVersion: MANAGED_VERSION } - mocks.recover.mockResolvedValueOnce({ - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_recovery_decommission_unsettled', - reason: 'no answer' - }) - await expect(stop()).resolves.toMatchObject({ outcome: 'refused', verdict: 'unverifiable' }) - expectStillLinked() - mocks.recover.mockResolvedValueOnce({ - outcome: 'recovered', - resolution: 'committed', - activeVersion: null, - readiness: null - }) - await expect(stop()).resolves.toMatchObject({ outcome: 'unlinked', verdict: 'exited' }) - expect(mocks.decommission).not.toHaveBeenCalled() - }) - - it('refuses while another interrupted operation holds the journal', async () => { - mocks.state.transaction = { operation: 'activate' } - await expect(stop()).resolves.toMatchObject({ code: 'orcad_activation_recovery_required' }) - expectStillLinked() - }) - - it('unlinks a server whose record already shows nothing active', async () => { - mocks.resolveContext.mockImplementation(async () => harness.context({ active: null })) - await expect(stop()).resolves.toMatchObject({ outcome: 'unlinked', stoppedVersion: null }) - expect(mocks.decommission).not.toHaveBeenCalled() - }) -}) - -describe('cancelManagedOrcadStop', () => { - const request = { transactionId: 't-1', version: MANAGED_VERSION } - const cancel = () => cancelManagedOrcadStop(harness.userDataPath, { selector: 'Managed' }) - - it('has nothing to cancel without a decommission journal', async () => { - await expect(cancel()).resolves.toEqual({ outcome: 'none' }) - expect(mocks.cancel).not.toHaveBeenCalled() - }) - - it('withdraws a stop orcad never acted on and keeps the server serving', async () => { - mocks.state.transaction = { - operation: 'decommission', - phase: 'stop-dispatched', - activeVersion: MANAGED_VERSION, - request - } - mocks.cancel.mockResolvedValueOnce({ outcome: 'canceled' }) - mocks.keepServing.mockResolvedValueOnce({ - outcome: 'recovered', - resolution: 'restored-incumbent', - activeVersion: MANAGED_VERSION, - readiness: null - }) - await expect(cancel()).resolves.toEqual({ outcome: 'canceled', activeVersion: MANAGED_VERSION }) - expect(mocks.keepServing.mock.calls[0]?.[1]).toEqual({ - action: 'keep-serving', - version: MANAGED_VERSION - }) - expect(mocks.ensureTunnel).toHaveBeenCalledOnce() - expectStillLinked() - }) - - it('refuses once orcad already acted on the stop', async () => { - mocks.state.transaction = { - operation: 'decommission', - phase: 'stop-dispatched', - activeVersion: MANAGED_VERSION, - request - } - mocks.cancel.mockResolvedValueOnce({ outcome: 'dispatched' }) - await expect(cancel()).resolves.toMatchObject({ - outcome: 'refused', - verdict: 'live', - code: 'orcad_stop_already_dispatched' - }) - expect(mocks.keepServing).not.toHaveBeenCalled() - }) - - it('reports an already-exited stop for stop to finish', async () => { - mocks.state.transaction = { - operation: 'decommission', - phase: 'process-exited', - activeVersion: MANAGED_VERSION, - request - } - await expect(cancel()).resolves.toEqual({ outcome: 'already-stopped' }) - expectStillLinked() - }) -}) diff --git a/src/main/ssh/orcad-runtime-decommission.ts b/src/main/ssh/orcad-runtime-decommission.ts deleted file mode 100644 index 4eb9f335fa0a..000000000000 --- a/src/main/ssh/orcad-runtime-decommission.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Stopping a managed orcad and unlinking it, on T6-4's journaled decommission. The server stays - * linked — its SSH claim, tunnel and deployment record — unless the host proves orcad exited. - */ -import type { - OrcadManagedCancelStopResult, - OrcadManagedStopResult -} from '../../shared/orcad-managed-runtime' -import { removeManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' -import type { - KnownRuntimeEnvironment, - OrcadDeploymentLink -} from '../../shared/runtime-environments' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { withStaleOrcadActivationRecoveryLock } from './orcad-activation-lock' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { reconcileOrcadDecommission } from './orcad-decommission-recovery' -import { cancelRemoteOrcadManagedStop } from './orcad-managed-remote-stop' -import { - managedOrcadSlot, - requireManagedOrcadInfrastructure, - resolveLinkedOrcadContext -} from './orcad-managed-runtime-context' -import { closeOrcadManagedTunnel, ensureOrcadManagedTunnel } from './orcad-managed-tunnel' -import { clearManagedOrcadUpdateDeferral } from './orcad-managed-update-deferrals' -import { decommissionRemoteOrcad } from './orcad-remote-stop' -import { withManagedOrcadLifecycle } from './orcad-runtime-maintenance' -import { collectManagedTerminalCensus } from './orcad-terminal-census-client' -import { RemoteInstallLockBusyError } from './ssh-relay-install-lock' - -export type ManagedOrcadStopPolicy = { - isActiveEnvironment: (environmentId: string) => boolean - /** Invalidates the server's transport and retires its client-side state once it is unlinked. */ - retireLocalState: (environmentId: string) => Promise | void -} - -type Refusal = Extract -type Stopped = { version: string | null; retirement: 'retired' | 'live' | 'unverifiable' | null } - -function refuse(verdict: Refusal['verdict'], code: string, reason: string): Refusal { - return { outcome: 'refused', verdict, code, reason } -} - -export function stopManagedOrcadEnvironment( - userDataPath: string, - args: { selector: string; signal?: AbortSignal }, - policy: ManagedOrcadStopPolicy -): Promise { - return withManagedOrcadLifecycle(userDataPath, args.selector, async (managed) => { - const { environment, deployment } = managed - if (policy.isActiveEnvironment(environment.id)) { - return refuse( - 'live', - 'orcad_stop_active_environment', - 'Choose another Active Server in Advanced before stopping this server.' - ) - } - const stopped = await stopRemote(userDataPath, environment, deployment, args.signal) - if ('outcome' in stopped) { - return stopped - } - await unlinkStoppedEnvironment(userDataPath, environment, deployment, policy) - return { - outcome: 'unlinked', - verdict: 'exited', - environmentId: environment.id, - sshTargetId: deployment.sshTargetId, - stoppedVersion: stopped.version, - retirement: stopped.retirement - } - }) -} - -/** Proven exit, or the refusal that keeps the server linked. */ -async function stopRemote( - userDataPath: string, - environment: KnownRuntimeEnvironment, - deployment: OrcadDeploymentLink, - signal?: AbortSignal -): Promise { - const context = await resolveLinkedOrcadContext(environment, deployment, signal) - const slot = managedOrcadSlot(context, deployment.remotePort, signal) - const transaction = await readOrcadActivationTransaction(slot) - if (transaction?.operation === 'decommission') { - // An earlier stop was interrupted: its journal decides, so finish exactly that one. - const recovered = await recoverInterruptedOrcadActivation(slot) - if (recovered.outcome === 'recovered' && recovered.activeVersion === null) { - return { version: transaction.activeVersion, retirement: null } - } - if (recovered.outcome === 'refused') { - return refuse(recovered.verdict, recovered.code, recovered.reason) - } - if (recovered.outcome === 'pending') { - return refuse('unverifiable', recovered.code, recovered.reason) - } - return refuse( - 'live', - 'orcad_stop_withdrawn', - 'The interrupted stop was withdrawn and the server keeps serving. Stop it again.' - ) - } - if (transaction) { - return refuse( - 'unverifiable', - 'orcad_activation_recovery_required', - 'An earlier update on this server was interrupted. Recover it before stopping.' - ) - } - const record = context.activationRecord - if (!record.active) { - // Only a proven exit (or a deploy that never activated) leaves the record without a version. - return { version: null, retirement: null } - } - const census = await collectManagedTerminalCensus(userDataPath, environment, record) - const result = await decommissionRemoteOrcad({ ...slot, record, census }) - if (result.outcome === 'refused') { - return refuse(result.verdict, result.code, result.reason) - } - return { version: result.version, retirement: result.retirement } -} - -async function unlinkStoppedEnvironment( - userDataPath: string, - environment: KnownRuntimeEnvironment, - deployment: OrcadDeploymentLink, - policy: ManagedOrcadStopPolicy -): Promise { - // Environment first: a crash before the claim is released leaves a hidden target to resume, - // never a linked server whose SSH target was already handed back. - removeManagedOrcadEnvironment(userDataPath, environment.id) - await policy.retireLocalState(environment.id) - await closeOrcadManagedTunnel(environment.id) - const { claims } = requireManagedOrcadInfrastructure() - claims.release(deployment.sshTargetId, environment.id) - clearManagedOrcadUpdateDeferral(environment.id) - await claims.flush() -} - -/** Withdraws a stop orcad has not acted on yet; the server then keeps serving. */ -export function cancelManagedOrcadStop( - userDataPath: string, - args: { selector: string; signal?: AbortSignal } -): Promise { - return withManagedOrcadLifecycle(userDataPath, args.selector, async (managed) => { - const { environment, deployment } = managed - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const slot = managedOrcadSlot(context, deployment.remotePort, args.signal) - let result: OrcadManagedCancelStopResult - try { - result = await withStaleOrcadActivationRecoveryLock(slot, async (lock) => { - const transaction = await readOrcadActivationTransaction(slot) - if (transaction?.operation !== 'decommission') { - if (transaction) { - lock.retain() - } - return { outcome: 'none' } - } - if (transaction.phase === 'process-exited') { - lock.retain() - return { outcome: 'already-stopped' } - } - if (transaction.request) { - const cancellation = await cancelRemoteOrcadManagedStop(slot, transaction.request) - if (cancellation.outcome !== 'canceled') { - lock.retain() - return { - outcome: 'refused', - verdict: 'live', - code: 'orcad_stop_already_dispatched', - reason: 'orcad already acted on the stop and is shutting down; it cannot be canceled.' - } - } - } - const kept = await reconcileOrcadDecommission(slot, { - action: 'keep-serving', - version: transaction.activeVersion - }) - if (kept.outcome !== 'recovered' || !kept.activeVersion) { - lock.retain() - return { - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_stop_cancel_unverifiable', - reason: 'The stop was withdrawn but the server could not be shown to be serving.' - } - } - return { outcome: 'canceled', activeVersion: kept.activeVersion } - }) - } catch (error) { - if (error instanceof RemoteInstallLockBusyError) { - return { - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_stop_still_running', - reason: 'A stop on this server may still be running. Retry after it settles.' - } - } - throw error - } - if (result.outcome === 'canceled') { - await ensureOrcadManagedTunnel(userDataPath, environment.id) - } - return result - }) -} diff --git a/src/main/ssh/orcad-runtime-deployment.test.ts b/src/main/ssh/orcad-runtime-deployment.test.ts index f0f66ec5e5c4..2faf1d7cb5df 100644 --- a/src/main/ssh/orcad-runtime-deployment.test.ts +++ b/src/main/ssh/orcad-runtime-deployment.test.ts @@ -46,13 +46,6 @@ vi.mock('./orcad-artifact-materializer', () => ({ })) vi.mock('./orcad-local-build-hash', () => ({ computeLocalOrcadBuildHash: () => 'build-hash' })) vi.mock('./orcad-active-readiness', () => ({ probeActiveOrcadReadiness: mocks.probe })) -vi.mock('./orcad-terminal-census-client', () => ({ - collectManagedTerminalCensus: async () => ({ - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 39 - }) -})) vi.mock('./orcad-managed-tunnel', () => ({ startOrcadManagedTunnel: mocks.startTunnel, ensureOrcadManagedTunnel: mocks.ensureTunnel, @@ -337,9 +330,7 @@ describe('getManagedOrcadRuntimeStatus', () => { phase: 'candidate-ready', version: '0.2.0+def', startedAt: 'then' - }, - terminals: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 39 }, - deferredUpdate: null + } }) expect(mocks.recover).toHaveBeenCalledTimes(1) }) diff --git a/src/main/ssh/orcad-runtime-deployment.ts b/src/main/ssh/orcad-runtime-deployment.ts index fe4ee7b79a6c..575ad10da7e4 100644 --- a/src/main/ssh/orcad-runtime-deployment.ts +++ b/src/main/ssh/orcad-runtime-deployment.ts @@ -28,7 +28,8 @@ import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' import { isForceableOrcadDeferral, - managedOrcadSlot, + MANAGED_ORCAD_LEGACY_NODE_PATH, + ORCAD_BIND_HOST, probeManagedOrcadReadiness, requireManagedOrcadInfrastructure } from './orcad-managed-runtime-context' @@ -69,7 +70,16 @@ export async function createManagedOrcadEnvironment( try { const connection = await connectionManager.connect(claimed) let context = await resolveOrcadRemoteContext(claimed, connection, args.signal) - const slot = managedOrcadSlot(context, ORCAD_MANAGED_REMOTE_PORT, args.signal) + const slot = { + conn: context.connection, + host: context.host, + remoteHome: context.remoteHome, + nodePath: MANAGED_ORCAD_LEGACY_NODE_PATH, + userDataDir: context.userDataDir, + bindHost: ORCAD_BIND_HOST, + port: ORCAD_MANAGED_REMOTE_PORT, + signal: args.signal + } const recovery = await recoverInterruptedOrcadActivation(slot) if (recovery.outcome === 'pending' || recovery.outcome === 'refused') { throw new Error(recovery.reason) diff --git a/src/main/ssh/orcad-runtime-lifecycle.ts b/src/main/ssh/orcad-runtime-lifecycle.ts index 0562ccce7c74..247f6ca1732b 100644 --- a/src/main/ssh/orcad-runtime-lifecycle.ts +++ b/src/main/ssh/orcad-runtime-lifecycle.ts @@ -1,8 +1,2 @@ export { createManagedOrcadEnvironment } from './orcad-runtime-deployment' export { getManagedOrcadRuntimeStatus } from './orcad-runtime-status' -export { - recoverManagedOrcadEnvironment, - rollbackManagedOrcadEnvironment, - updateManagedOrcadEnvironment -} from './orcad-runtime-maintenance' -export { cancelManagedOrcadStop, stopManagedOrcadEnvironment } from './orcad-runtime-decommission' diff --git a/src/main/ssh/orcad-runtime-maintenance.test.ts b/src/main/ssh/orcad-runtime-maintenance.test.ts deleted file mode 100644 index c150dc7b59dd..000000000000 --- a/src/main/ssh/orcad-runtime-maintenance.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { rmSync } from 'node:fs' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { listEnvironments } from '../../shared/runtime-environment-store' -import { - createManagedLifecycleHarness, - MANAGED_PREVIOUS_VERSION, - MANAGED_VERSION, - managedReadiness -} from './orcad-managed-lifecycle-test-fixture' - -const mocks = vi.hoisted(() => { - const state: { store: unknown } = { store: null } - return { - state, - resolveContext: vi.fn(), - census: vi.fn(), - deploy: vi.fn(), - rollback: vi.fn(), - recover: vi.fn(), - probe: vi.fn(), - buildHash: vi.fn(), - ensureTunnel: vi.fn() - } -}) - -vi.mock('./ssh-target-registry', () => ({ - getSshConnectionManager: () => ({ connect: async () => ({}) }), - getSshTargetRegistryStore: () => mocks.state.store -})) -vi.mock('./orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext })) -vi.mock('./orcad-terminal-census-client', () => ({ collectManagedTerminalCensus: mocks.census })) -vi.mock('./orcad-remote-deploy', () => ({ deployOrcad: mocks.deploy })) -vi.mock('./orcad-remote-rollback', () => ({ rollbackOrcad: mocks.rollback })) -vi.mock('./orcad-activation-recovery', () => ({ recoverInterruptedOrcadActivation: mocks.recover })) -vi.mock('./orcad-active-readiness', () => ({ probeActiveOrcadReadiness: mocks.probe })) -vi.mock('./orcad-remote-build-hash', () => ({ readRemoteOrcadBuildHash: mocks.buildHash })) -vi.mock('./orcad-artifact-materializer', () => ({ - materializeOrcadArtifact: async () => '/local/orcad' -})) -vi.mock('./orcad-local-build-hash', () => ({ computeLocalOrcadBuildHash: () => 'local-hash' })) -vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensureTunnel })) -vi.mock('./orcad-activation-transaction-store', () => ({ - readOrcadActivationTransaction: async () => null -})) - -const { - getManagedOrcadRuntimeStatus, - recoverManagedOrcadEnvironment, - rollbackManagedOrcadEnvironment, - updateManagedOrcadEnvironment -} = await import('./orcad-runtime-lifecycle') - -const idle = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 7 } -let harness: ReturnType - -beforeEach(() => { - vi.resetAllMocks() - harness = createManagedLifecycleHarness() - mocks.state.store = harness.targetStore - mocks.resolveContext.mockImplementation(async () => harness.context()) - mocks.census.mockResolvedValue(idle) - mocks.probe.mockResolvedValue(managedReadiness()) - mocks.buildHash.mockResolvedValue('previous-hash') - mocks.recover.mockResolvedValue({ outcome: 'none' }) -}) - -afterEach(() => rmSync(harness.userDataPath, { recursive: true, force: true })) - -describe('updateManagedOrcadEnvironment', () => { - it('defers over live or unverifiable terminals and reports the deferral in status', async () => { - mocks.census.mockResolvedValue({ ...idle, liveSessions: null }) - mocks.deploy.mockResolvedValueOnce({ - outcome: 'installed-not-activated', - fullVersion: '0.3.0+new', - code: 'orcad_update_terminal_census_unavailable', - reason: 'The terminal daemon did not answer a session count.' - }) - const result = await updateManagedOrcadEnvironment(harness.userDataPath, { - selector: 'Managed' - }) - expect(result).toMatchObject({ outcome: 'deferred', forceable: false }) - expect(mocks.deploy.mock.calls[0]?.[0]).toMatchObject({ - census: { liveSessions: null }, - port: 6_768, - nodePath: '' - }) - const status = await getManagedOrcadRuntimeStatus(harness.userDataPath, 'Managed') - expect(status.deferredUpdate).toMatchObject({ - candidateVersion: '0.3.0+new', - code: 'orcad_update_terminal_census_unavailable' - }) - expect(status.terminals.liveSessions).toBeNull() - }) - - it('clears the deferral once an update goes through and keeps an unchanged pairing as is', async () => { - mocks.deploy.mockResolvedValueOnce({ - outcome: 'installed-not-activated', - fullVersion: '0.3.0+new', - code: 'orcad_update_terminals_running', - reason: 'busy' - }) - await updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - mocks.deploy.mockResolvedValueOnce({ - outcome: 'installed-and-activated', - fullVersion: '0.3.0+new' - }) - const result = await updateManagedOrcadEnvironment(harness.userDataPath, { - selector: 'Managed', - force: true - }) - expect(result).toMatchObject({ outcome: 'updated', activeVersion: '0.3.0+new' }) - expect(mocks.probe).toHaveBeenCalledWith(expect.anything(), { - buildHash: 'local-hash', - fullVersion: '0.3.0+new' - }) - const [environment] = listEnvironments(harness.userDataPath) - expect(environment?.pairingRevision).toBe(harness.environment.pairingRevision) - expect(environment?.orcadDeployment).toEqual(harness.environment.orcadDeployment) - const status = await getManagedOrcadRuntimeStatus(harness.userDataPath, 'Managed') - expect(status.deferredUpdate).toBeNull() - }) - - it('re-pairs through the same tunnel, keeping the deployment link, when the offer changed', async () => { - mocks.deploy.mockResolvedValueOnce({ outcome: 'installed-and-activated', fullVersion: 'v3' }) - mocks.probe.mockResolvedValueOnce(managedReadiness('rotated-token')) - await updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - const [environment] = listEnvironments(harness.userDataPath) - expect(environment?.endpoints[0]?.deviceToken).toBe('rotated-token') - expect(environment?.orcadDeployment).toEqual(harness.environment.orcadDeployment) - }) -}) - -describe('rollbackManagedOrcadEnvironment', () => { - it.each([ - [{ ...idle, liveSessions: 2 }, 'orcad_rollback_terminals_running'], - [{ ...idle, liveSessions: null }, 'orcad_rollback_census_unavailable'] - ])('refuses while terminals run or cannot be counted: %j', async (census, code) => { - mocks.census.mockResolvedValue(census) - await expect( - rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'refused', code }) - expect(mocks.rollback).not.toHaveBeenCalled() - }) - - it('refuses with no previous version without contacting the slot', async () => { - mocks.resolveContext.mockImplementation(async () => harness.context({ previous: null })) - await expect( - rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'refused', code: 'orcad_rollback_no_target' }) - expect(mocks.census).not.toHaveBeenCalled() - }) - - it('rolls back against the installed bytes of the previous slot', async () => { - mocks.rollback.mockResolvedValueOnce({ - outcome: 'rolled-back', - target: MANAGED_PREVIOUS_VERSION, - discarded: [MANAGED_VERSION], - verdict: { decision: 'activate', coverage: 'pty-spawn', warnings: [] } - }) - await expect( - rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'rolled-back', activeVersion: MANAGED_PREVIOUS_VERSION }) - expect(mocks.rollback.mock.calls[0]?.[0]).toMatchObject({ targetBuildHash: 'previous-hash' }) - expect(mocks.probe).toHaveBeenCalledWith(expect.anything(), { - buildHash: 'previous-hash', - fullVersion: MANAGED_PREVIOUS_VERSION - }) - }) -}) - -describe('recoverManagedOrcadEnvironment', () => { - it('passes a refusal through and leaves the server linked', async () => { - mocks.recover.mockResolvedValueOnce({ - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_recovery_unverifiable', - reason: 'host fenced' - }) - await expect( - recoverManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'refused', verdict: 'unverifiable' }) - expect(listEnvironments(harness.userDataPath)).toHaveLength(1) - }) - - it('re-ensures the tunnel once a serving slot is restored', async () => { - mocks.recover.mockResolvedValueOnce({ - outcome: 'recovered', - resolution: 'restored-incumbent', - activeVersion: MANAGED_VERSION, - readiness: managedReadiness() - }) - await expect( - recoverManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'recovered', activeVersion: MANAGED_VERSION }) - expect(mocks.ensureTunnel).toHaveBeenCalledWith(harness.userDataPath, 'environment-1') - }) -}) diff --git a/src/main/ssh/orcad-runtime-maintenance.ts b/src/main/ssh/orcad-runtime-maintenance.ts deleted file mode 100644 index 712d0d67ae00..000000000000 --- a/src/main/ssh/orcad-runtime-maintenance.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * Updating, rolling back and recovering a managed orcad, on T6-2's deploy, rollback and recovery. - * Every step reads the terminal census through the server's tunnel first; an unanswered census - * is unverifiable, never zero, so an update over live or uncounted terminals defers (D7). - */ -import { refreshManagedOrcadPairing } from '../../shared/runtime-environment-managed-orcad-store' -import { assertRuntimeEnvironmentNotReconciling } from '../../shared/runtime-environment-reconciliation-record' -import { - redactRuntimeEnvironment, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import type { - OrcadManagedDeployResult, - OrcadManagedRecoveryResult, - OrcadManagedRollbackResult -} from '../../shared/orcad-managed-runtime' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import type { ServeReadiness } from '../server/serve-readiness' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { probeActiveOrcadReadiness } from './orcad-active-readiness' -import { materializeOrcadArtifact } from './orcad-artifact-materializer' -import { CURRENT_ORCAD_DAEMON_PROTOCOL } from './orcad-daemon-protocol-crossing' -import { ensureOrcadManagedTunnel } from './orcad-managed-tunnel' -import { - clearManagedOrcadUpdateDeferral, - recordManagedOrcadUpdateDeferral -} from './orcad-managed-update-deferrals' -import { - isForceableOrcadDeferral, - managedOrcadInstallDir, - managedOrcadSlot, - probeManagedOrcadReadiness, - requireManagedOrcadEnvironment, - resolveLinkedOrcadContext -} from './orcad-managed-runtime-context' -import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash' -import { deployOrcad } from './orcad-remote-deploy' -import { rollbackOrcad } from './orcad-remote-rollback' -import { collectManagedTerminalCensus } from './orcad-terminal-census-client' -import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' - -type LifecycleArgs = { selector: string; signal?: AbortSignal } - -export function withManagedOrcadLifecycle( - userDataPath: string, - selector: string, - run: (managed: ReturnType) => Promise -): Promise { - const { environment, deployment } = requireManagedOrcadEnvironment(userDataPath, selector) - return runTargetLifecycle(deployment.sshTargetId, async () => { - // Re-read under the queue: a reconciliation or stop may have won the race for it. - const current = requireManagedOrcadEnvironment(userDataPath, environment.id) - assertRuntimeEnvironmentNotReconciling(current.environment) - return run(current) - }) -} - -function refreshPairing( - userDataPath: string, - environment: KnownRuntimeEnvironment, - readiness: ServeReadiness, - localPort: number -): KnownRuntimeEnvironment { - return refreshManagedOrcadPairing( - userDataPath, - environment.id, - tunneledOrcadPairingCode(readiness, localPort) - ) -} - -export function updateManagedOrcadEnvironment( - userDataPath: string, - args: LifecycleArgs & { force?: boolean } -): Promise { - return withManagedOrcadLifecycle( - userDataPath, - args.selector, - async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const census = await collectManagedTerminalCensus( - userDataPath, - environment, - context.activationRecord - ) - const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { - signal: args.signal - }) - const result = await deployOrcad({ - ...managedOrcadSlot(context, deployment.remotePort, args.signal), - localOrcadDir, - target: context.serverTarget, - census, - force: args.force - }) - if (result.outcome === 'installed-not-activated') { - const deferral = { - outcome: 'deferred' as const, - candidateVersion: result.fullVersion, - code: result.code, - reason: result.reason, - forceable: isForceableOrcadDeferral(result.code) - } - recordManagedOrcadUpdateDeferral(environment.id, deferral) - return deferral - } - clearManagedOrcadUpdateDeferral(environment.id) - const readiness = await probeManagedOrcadReadiness( - context, - localOrcadDir, - result.fullVersion, - args.signal - ) - const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) - return { - outcome: result.outcome === 'already-active' ? 'already-current' : 'updated', - environment: redactRuntimeEnvironment(updated), - activeVersion: result.fullVersion - } - } - ) -} - -export function rollbackManagedOrcadEnvironment( - userDataPath: string, - args: LifecycleArgs -): Promise { - return withManagedOrcadLifecycle( - userDataPath, - args.selector, - async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const record = context.activationRecord - const target = record.previous - if (!target) { - return { - outcome: 'refused', - code: 'orcad_rollback_no_target', - reason: 'This server has no previous version to roll back to.' - } - } - const census = await collectManagedTerminalCensus(userDataPath, environment, record) - // Why idle only: this client cannot read the older build's daemon protocol, so it cannot show - // that build would reach terminals that are still running. - if (census.liveSessions !== 0) { - return { - outcome: 'refused', - code: - census.liveSessions === null - ? 'orcad_rollback_census_unavailable' - : 'orcad_rollback_terminals_running', - reason: - census.liveSessions === null - ? 'The server did not answer how many terminals it runs. Retry when it answers.' - : 'Close the terminals running on this server before rolling it back.' - } - } - const slot = managedOrcadSlot(context, deployment.remotePort, args.signal) - const targetDir = managedOrcadInstallDir(context, target) - const targetBuildHash = await readRemoteOrcadBuildHash(slot, targetDir) - const result = await rollbackOrcad({ - ...slot, - record, - census, - targetBuildHash, - targetDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL - }) - if (result.outcome !== 'rolled-back') { - return result - } - const readiness = await probeActiveOrcadReadiness( - { ...slot, remoteInstallDir: targetDir }, - { buildHash: targetBuildHash, fullVersion: result.target } - ) - const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) - return { - outcome: 'rolled-back', - environment: redactRuntimeEnvironment(updated), - activeVersion: result.target, - discarded: result.discarded - } - } - ) -} - -/** Finishes or undoes an interrupted activation, rollback or decommission on the host. */ -export function recoverManagedOrcadEnvironment( - userDataPath: string, - args: LifecycleArgs -): Promise { - return withManagedOrcadLifecycle( - userDataPath, - args.selector, - async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const result = await recoverInterruptedOrcadActivation( - managedOrcadSlot(context, deployment.remotePort, args.signal) - ) - if (result.outcome !== 'recovered') { - return result - } - const updated = result.readiness - ? refreshPairing(userDataPath, environment, result.readiness, deployment.localPort) - : environment - if (result.activeVersion) { - await ensureOrcadManagedTunnel(userDataPath, environment.id) - } - return { - outcome: 'recovered', - resolution: result.resolution, - activeVersion: result.activeVersion, - environment: redactRuntimeEnvironment(updated) - } - } - ) -} diff --git a/src/main/ssh/orcad-runtime-status.ts b/src/main/ssh/orcad-runtime-status.ts index 101eb6d81e58..639bd7a41994 100644 --- a/src/main/ssh/orcad-runtime-status.ts +++ b/src/main/ssh/orcad-runtime-status.ts @@ -6,8 +6,6 @@ import { requireManagedOrcadEnvironment, resolveLinkedOrcadContext } from './orcad-managed-runtime-context' -import { readManagedOrcadUpdateDeferral } from './orcad-managed-update-deferrals' -import { collectManagedTerminalCensus } from './orcad-terminal-census-client' /** Read-only: what the host's activation record and journal say, without repairing either. */ export async function getManagedOrcadRuntimeStatus( @@ -32,9 +30,7 @@ export async function getManagedOrcadRuntimeStatus( previousVersion: record.previous, activatedAt: record.activatedAt, rollbackAvailable: Boolean(record.previous && record.snapshot), - recovery: transaction ? managedRecoveryStatus(transaction) : null, - terminals: await collectManagedTerminalCensus(userDataPath, environment, record), - deferredUpdate: readManagedOrcadUpdateDeferral(environment.id) + recovery: transaction ? managedRecoveryStatus(transaction) : null } }) } diff --git a/src/main/ssh/orcad-terminal-census-client.test.ts b/src/main/ssh/orcad-terminal-census-client.test.ts deleted file mode 100644 index feb3bb15cd95..000000000000 --- a/src/main/ssh/orcad-terminal-census-client.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY } from '../../shared/protocol-version' -import { - createEnvironmentFromPairingOffer, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { emptyOrcadActivationRecord } from './orcad-activation-record' - -const mocks = vi.hoisted(() => ({ send: vi.fn(), ensure: vi.fn() })) -vi.mock('../../shared/remote-runtime-client', () => ({ - sendRemoteRuntimeRequestWithStatusPreflight: mocks.send -})) -vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensure })) - -const { collectManagedTerminalCensus, collectRemoteOrcadTerminalCensus } = - await import('./orcad-terminal-census-client') - -const environment: KnownRuntimeEnvironment = createEnvironmentFromPairingOffer({ - id: 'environment-1', - name: 'Managed', - now: 1, - offer: { v: 2, endpoint: 'ws://127.0.0.1:46768', deviceToken: 't', publicKeyB64: 'k' }, - connectionDependency: 'ssh-tunnel' -}) -const active = { - ...emptyOrcadActivationRecord(), - active: '1.0.0', - activatedAt: '2026-01-01T00:00:00.000Z' -} -const unverifiable = { - liveSessions: null, - startedSinceActivation: null, - daemonProtocolVersion: null -} -const census = { liveSessions: 2, startedSinceActivation: 1, daemonProtocolVersion: 7 } - -function answerWith(capabilities: string[], response: unknown) { - mocks.send.mockImplementation(async (_pairing, _method, _params, _timeout, validate) => { - validate({ ok: true, result: { capabilities } }) - return response - }) -} - -describe('managed orcad terminal census client', () => { - beforeEach(() => { - vi.resetAllMocks() - mocks.ensure.mockResolvedValue(undefined) - }) - - it('reads an idle census without contacting a host that has nothing active', async () => { - await expect( - collectRemoteOrcadTerminalCensus(environment, { ...active, active: null }) - ).resolves.toEqual({ liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }) - expect(mocks.send).not.toHaveBeenCalled() - }) - - it('asks an advertising host with the activation time', async () => { - answerWith([ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY], { ok: true, result: census }) - await expect(collectRemoteOrcadTerminalCensus(environment, active)).resolves.toEqual(census) - expect(mocks.send.mock.calls[0]?.slice(1, 3)).toEqual([ - 'orcad.terminalCensus', - { activatedAt: Date.parse(active.activatedAt) } - ]) - }) - - it.each([ - ['an older host without the capability', () => answerWith([], { ok: true, result: census })], - [ - 'method not found', - () => - answerWith([ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY], { - ok: false, - error: { code: 'method_not_found', message: 'no' } - }) - ], - [ - 'a malformed answer', - () => answerWith([ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY], { ok: true, result: {} }) - ], - ['loss of contact', () => mocks.send.mockRejectedValue(new Error('socket closed'))] - ])('reads %s as unverifiable, never as zero', async (_label, arrange) => { - arrange() - await expect(collectRemoteOrcadTerminalCensus(environment, active)).resolves.toEqual( - unverifiable - ) - }) - - it('reads an unparseable activation time as unverifiable', async () => { - await expect( - collectRemoteOrcadTerminalCensus(environment, { ...active, activatedAt: 'later' }) - ).resolves.toEqual(unverifiable) - expect(mocks.send).not.toHaveBeenCalled() - }) - - it('reads a tunnel that cannot open as unverifiable', async () => { - mocks.ensure.mockRejectedValue(new Error('SSH unavailable')) - await expect(collectManagedTerminalCensus('/profile', environment, active)).resolves.toEqual( - unverifiable - ) - }) -}) diff --git a/src/main/ssh/orcad-terminal-census-client.ts b/src/main/ssh/orcad-terminal-census-client.ts deleted file mode 100644 index d039fa8e120a..000000000000 --- a/src/main/ssh/orcad-terminal-census-client.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Asks a managed orcad, through its tunnel, how many terminals its daemon runs. Every failure, - * including an older host without the method, reads as an unverifiable census, never as zero. - */ -import { ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY } from '../../shared/protocol-version' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' -import { - getPreferredPairingOffer, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { - ORCAD_TERMINAL_CENSUS_METHOD, - OrcadTerminalCensusSchema, - type OrcadTerminalCensus -} from '../../shared/orcad-terminal-census' -import { sendRemoteRuntimeRequestWithStatusPreflight } from '../../shared/remote-runtime-client' -import type { OrcadActivationRecord } from './orcad-activation-record' -import { ensureOrcadManagedTunnel } from './orcad-managed-tunnel' - -export const UNVERIFIABLE_ORCAD_TERMINAL_CENSUS: OrcadTerminalCensus = { - liveSessions: null, - startedSinceActivation: null, - daemonProtocolVersion: null -} - -export async function collectRemoteOrcadTerminalCensus( - environment: KnownRuntimeEnvironment, - record: OrcadActivationRecord, - timeoutMs = 15_000 -): Promise { - if (!record.active) { - return collectIdle() - } - const activatedAt = record.activatedAt ? Date.parse(record.activatedAt) : Number.NaN - if (!Number.isFinite(activatedAt) || activatedAt < 0) { - return UNVERIFIABLE_ORCAD_TERMINAL_CENSUS - } - try { - const response = await sendRemoteRuntimeRequestWithStatusPreflight( - getPreferredPairingOffer(environment), - ORCAD_TERMINAL_CENSUS_METHOD, - { activatedAt }, - timeoutMs, - (status) => { - if ( - !status.ok || - !status.result.capabilities?.includes(ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY) - ) { - throw new Error('The managed Orca server does not report a terminal census.') - } - }, - undefined, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - return response.ok - ? OrcadTerminalCensusSchema.parse(response.result) - : UNVERIFIABLE_ORCAD_TERMINAL_CENSUS - } catch { - return UNVERIFIABLE_ORCAD_TERMINAL_CENSUS - } -} - -/** The census through the server's ensured tunnel; a tunnel that cannot open is unverifiable. */ -export async function collectManagedTerminalCensus( - userDataPath: string, - environment: KnownRuntimeEnvironment, - record: OrcadActivationRecord -): Promise { - if (!record.active) { - return collectIdle() - } - try { - await ensureOrcadManagedTunnel(userDataPath, environment.id) - } catch { - return UNVERIFIABLE_ORCAD_TERMINAL_CENSUS - } - return collectRemoteOrcadTerminalCensus(environment, record) -} - -function collectIdle(): OrcadTerminalCensus { - return { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null } -} diff --git a/src/shared/orcad-managed-runtime.ts b/src/shared/orcad-managed-runtime.ts index 85a13fb2ddee..8f070d748388 100644 --- a/src/shared/orcad-managed-runtime.ts +++ b/src/shared/orcad-managed-runtime.ts @@ -1,64 +1,21 @@ import type { PublicKnownRuntimeEnvironment } from './runtime-environments' -import type { OrcadTerminalCensus } from './orcad-terminal-census' export const ORCAD_MANAGED_REMOTE_PORT = 6_768 -export type OrcadManagedDeferral = { - outcome: 'deferred' - candidateVersion: string - code: string - reason: string - /** False when force cannot make the rejected lifecycle transition safe. */ - forceable?: boolean -} - export type OrcadManagedDeployResult = | { - outcome: 'created' | 'updated' | 'already-current' + outcome: 'created' | 'already-current' environment: PublicKnownRuntimeEnvironment activeVersion: string } - | OrcadManagedDeferral - -export type OrcadManagedRollbackResult = | { - outcome: 'rolled-back' - environment: PublicKnownRuntimeEnvironment - activeVersion: string - discarded: string[] + outcome: 'deferred' + candidateVersion: string + code: string + reason: string + /** False when force cannot make the rejected lifecycle transition safe. */ + forceable?: boolean } - | { outcome: 'refused' | 'failed'; code: string; reason: string } - -export type OrcadManagedRecoveryResult = - | { outcome: 'none' } - | { outcome: 'pending'; code: string; reason: string } - | { - outcome: 'recovered' - resolution: 'committed' | 'restored-incumbent' - activeVersion: string | null - environment: PublicKnownRuntimeEnvironment - } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } - -/** Only a proven `exited` unlinks the server locally; anything less keeps it linked. */ -export type OrcadManagedStopResult = - | { - outcome: 'unlinked' - verdict: 'exited' - environmentId: string - sshTargetId: string - stoppedVersion: string | null - retirement: 'retired' | 'live' | 'unverifiable' | null - } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } - -export type OrcadManagedCancelStopResult = - | { outcome: 'none' } - /** The stop was withdrawn before orcad acted on it; the server keeps serving. */ - | { outcome: 'canceled'; activeVersion: string } - /** orcad had already exited; finish with stop to unlink the server. */ - | { outcome: 'already-stopped' } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } export type OrcadManagedRuntimeStatus = { environmentId: string @@ -92,8 +49,4 @@ export type OrcadManagedRuntimeStatus = { startedAt: string } | null - /** Terminals the daemon runs; `null` counts are unverifiable and block updates and stops. */ - terminals: OrcadTerminalCensus - /** The last update this client deferred for this server, cleared once one goes through. */ - deferredUpdate: (OrcadManagedDeferral & { deferredAt: string }) | null } diff --git a/src/shared/orcad-terminal-census.ts b/src/shared/orcad-terminal-census.ts index 11cdfb3449d4..c6a9bf23bee9 100644 --- a/src/shared/orcad-terminal-census.ts +++ b/src/shared/orcad-terminal-census.ts @@ -17,11 +17,3 @@ export const OrcadTerminalCensusSchema = z.object({ }) export type OrcadTerminalCensus = z.infer - -/** RPC a managed orcad answers with its census; clients call it only when advertised. */ -export const ORCAD_TERMINAL_CENSUS_METHOD = 'orcad.terminalCensus' - -export const OrcadTerminalCensusParamsSchema = z.object({ - /** The active version's activation time, epoch ms; sessions created at or after it count. */ - activatedAt: z.number().finite().nonnegative() -}) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index b44e78b82fd8..5d3217f00f3d 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -338,12 +338,7 @@ export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = export const AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY = 'agentSession.create.tab-id.v1' as const -// Why: older hosts answer orcad.terminalCensus with method-not-found, so a client asks only when -// this is advertised and otherwise treats the census as unverifiable, never as zero. -export const ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY = 'orcad.terminal-census.v1' as const - export const RUNTIME_CAPABILITIES = [ - ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY, ...AGENT_SESSION_STOP_RUNTIME_CAPABILITIES, AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index ca4f857e0fab..9b6680b08c28 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -23,7 +23,6 @@ import { PairingProvisionRelayParamsSchema } from '../mobile-relay-credential-contract' import { MobileWebBundleChunkParamsSchema } from '../mobile-web-bundle/bundle-rpc-contract' -import { OrcadTerminalCensusParamsSchema } from '../orcad-terminal-census' import { pluginConsentRequestSchema } from '../plugins/plugin-consent-request' import { AccountsUnsubscribeParams, @@ -988,7 +987,6 @@ export const RPC_PARAMS_BY_METHOD = { 'notifications.testPush': null, 'notifications.unregisterPush': null, 'notifications.unsubscribe': NotificationUnsubscribeParams, - 'orcad.terminalCensus': OrcadTerminalCensusParamsSchema, 'orchestration.ask': AskParams, 'orchestration.callerShow': null, 'orchestration.check': CheckParams, diff --git a/src/shared/runtime-environment-managed-orcad-store.test.ts b/src/shared/runtime-environment-managed-orcad-store.test.ts index 2458ad176fa2..4c5b08139acc 100644 --- a/src/shared/runtime-environment-managed-orcad-store.test.ts +++ b/src/shared/runtime-environment-managed-orcad-store.test.ts @@ -9,11 +9,7 @@ import { removeEnvironment, updateEnvironmentFromPairingCode } from './runtime-environment-store' -import { - addManagedOrcadEnvironment, - refreshManagedOrcadPairing, - removeManagedOrcadEnvironment -} from './runtime-environment-managed-orcad-store' +import { addManagedOrcadEnvironment } from './runtime-environment-managed-orcad-store' import { getRuntimeEnvironmentSidecarPath } from './runtime-environment-sidecar' import { getRuntimeSshAccess } from './runtime-environments' import { shippedBuildRewrite } from './runtime-environment-shipped-store-fixture' @@ -101,33 +97,4 @@ describe('managed orcad environment store', () => { ).toThrow('managed by Orca over SSH') expect(listEnvironments(userDataPath)).toHaveLength(1) }) - - it('removes the server and its deployment record together once it is unlinked', () => { - add() - removeManagedOrcadEnvironment(userDataPath, 'environment-1') - expect(listEnvironments(userDataPath)).toEqual([]) - expect(readFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), 'utf8')).not.toContain( - 'environment-1' - ) - }) - - it('leaves an unchanged pairing alone and re-binds the link when the offer rotates', () => { - const environment = add() - expect(refreshManagedOrcadPairing(userDataPath, 'environment-1', pairingCode())).toEqual( - environment - ) - const rotated = encodePairingOffer({ - v: 2, - endpoint: 'ws://127.0.0.1:46768', - deviceToken: 'rotated-token', - publicKeyB64: Buffer.alloc(32, 1).toString('base64') - }) - const refreshed = refreshManagedOrcadPairing(userDataPath, 'environment-1', rotated, 500) - expect(refreshed.endpoints[0]?.deviceToken).toBe('rotated-token') - expect(refreshed.pairingRevision).toBe(500) - expect(refreshed.orcadDeployment).toEqual(deployment) - expect(() => - refreshManagedOrcadPairing(userDataPath, 'environment-1', pairingCode('ws://127.0.0.1:1')) - ).toThrow('does not point at its SSH tunnel') - }) }) diff --git a/src/shared/runtime-environment-managed-orcad-store.ts b/src/shared/runtime-environment-managed-orcad-store.ts index 7ca7e1dd9b5c..91db33218f03 100644 --- a/src/shared/runtime-environment-managed-orcad-store.ts +++ b/src/shared/runtime-environment-managed-orcad-store.ts @@ -1,8 +1,7 @@ -import { parsePairingCode, type PairingOffer } from './pairing' +import { parsePairingCode } from './pairing' import { createEnvironmentFromPairingOffer, getPreferredLoopbackRuntimePort, - getPreferredPairingOffer, PersistedRuntimeEnvironmentSchema, type KnownRuntimeEnvironment, type OrcadDeploymentLink @@ -13,11 +12,7 @@ import { RuntimeEnvironmentStoreError, writeEnvironmentStore } from './runtime-environment-store-file' -import { - readCurrentRuntimeEnvironmentSidecarEntry, - writeRuntimeEnvironmentSidecarEntry -} from './runtime-environment-sidecar' -import { resolveEnvironmentFromStore } from './runtime-environment-store' +import { writeRuntimeEnvironmentSidecarEntry } from './runtime-environment-sidecar' /** Registers a server Orca deployed over SSH, paired through its own loopback tunnel. */ export function addManagedOrcadEnvironment( @@ -83,79 +78,3 @@ export function addManagedOrcadEnvironment( } return registered } - -/** - * Unlinks a managed server once its orcad is proven to have exited: drops the persisted entry - * and the sidecar record that held its deployment link. - */ -export function removeManagedOrcadEnvironment(userDataPath: string, environmentId: string): void { - const store = readPersistedEnvironmentStore(userDataPath) - const persisted = resolveEnvironmentFromStore(store, environmentId) - const remaining = store.environments.filter((entry) => entry.id !== persisted.id) - writeEnvironmentStore(userDataPath, { version: 1, environments: remaining }) - writeRuntimeEnvironmentSidecarEntry(userDataPath, remaining, persisted, null) -} - -/** Keeps a managed server's pairing current after its orcad changed versions. */ -export function refreshManagedOrcadPairing( - userDataPath: string, - environmentId: string, - pairingCode: string, - now = Date.now() -): KnownRuntimeEnvironment { - const offer = parsePairingCode(pairingCode) - if (!offer) { - throw new RuntimeEnvironmentStoreError('invalid_argument', 'Invalid managed pairing code.') - } - const current = resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environmentId) - const deployment = current.orcadDeployment - if (!deployment) { - throw new RuntimeEnvironmentStoreError('invalid_argument', 'This server is not managed.') - } - // Why skip: orcad keeps its pairing state across versions, so an unchanged offer needs no - // rewrite, and every rewrite re-binds the sidecar entry. - if (samePairing(getPreferredPairingOffer(current), offer)) { - return current - } - const store = readPersistedEnvironmentStore(userDataPath) - const existing = resolveEnvironmentFromStore(store, environmentId) - const entry = readCurrentRuntimeEnvironmentSidecarEntry(userDataPath, existing) - const next = PersistedRuntimeEnvironmentSchema.parse({ - ...createEnvironmentFromPairingOffer({ - id: existing.id, - name: existing.name, - now: existing.createdAt, - offer, - runtimeId: existing.runtimeId, - connectionDependency: 'ssh-tunnel' - }), - updatedAt: now, - pairingRevision: Math.max(now, (existing.pairingRevision ?? existing.createdAt) + 1), - lastUsedAt: existing.lastUsedAt - }) - if (getPreferredLoopbackRuntimePort(next) !== deployment.localPort) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - 'The managed pairing does not point at its SSH tunnel.' - ) - } - const environments = store.environments.map((candidate) => - candidate.id === existing.id ? next : candidate - ) - const { binding: _binding, ...state } = entry ?? {} - writeRuntimeEnvironmentSidecarEntry(userDataPath, environments, next, { - ...state, - orcadDeployment: deployment - }) - writeEnvironmentStore(userDataPath, { version: 1, environments }) - return resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environmentId) -} - -function samePairing(left: PairingOffer, right: PairingOffer): boolean { - return ( - left.endpoint === right.endpoint && - left.deviceToken === right.deviceToken && - left.publicKeyB64 === right.publicKeyB64 && - left.pairedDeviceId === right.pairedDeviceId - ) -} From 61bf4347b7fd461cfdd3ec683b4b36dbe373f0d1 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:02 -0700 Subject: [PATCH 03/26] Revert "feat(ssh): deploy and pair an empty managed orcad server over SSH (#16741 T6-5) (#24453)" This reverts commit 8b76683b400911b6348623d60e29f8ff5e21cbe7. --- .../orcad-runtime-lifecycle-handlers.test.ts | 66 --- .../ipc/orcad-runtime-lifecycle-handlers.ts | 37 -- .../orcad-ssh-provisioning-handlers.test.ts | 40 -- .../ipc/orcad-ssh-provisioning-handlers.ts | 20 - .../runtime-environment-handler-channels.ts | 9 +- .../ipc/runtime-environment-managed-tunnel.ts | 11 - .../ipc/runtime-environment-status-owner.ts | 8 - .../runtime-environment-transport-routing.ts | 8 +- .../ipc/runtime-environments-pairing.test.ts | 14 - src/main/ipc/runtime-environments.ts | 7 - .../ipc/runtime-ssh-access-handlers.test.ts | 89 ---- src/main/ipc/runtime-ssh-access-handlers.ts | 24 - src/main/ipc/ssh-host-sleep-reconnect.test.ts | 24 - src/main/ipc/ssh-host-sleep-reconnect.ts | 16 +- ...ssh-target-crud-handlers-ownership.test.ts | 74 --- src/main/ipc/ssh-target-crud-handlers.ts | 17 +- src/main/ipc/ssh.ts | 5 +- src/main/ssh/orcad-managed-runtime-context.ts | 99 ---- src/main/ssh/orcad-managed-tunnel-resume.ts | 270 ---------- src/main/ssh/orcad-managed-tunnel.test.ts | 504 ------------------ src/main/ssh/orcad-managed-tunnel.ts | 295 ---------- src/main/ssh/orcad-runtime-deployment.test.ts | 358 ------------- src/main/ssh/orcad-runtime-deployment.ts | 162 ------ src/main/ssh/orcad-runtime-lifecycle.ts | 2 - src/main/ssh/orcad-runtime-status.ts | 64 --- src/main/ssh/orcad-ssh-provisioning.test.ts | 245 --------- src/main/ssh/orcad-ssh-provisioning.ts | 200 ------- src/main/ssh/orcad-tunneled-pairing.test.ts | 48 -- src/main/ssh/orcad-tunneled-pairing.ts | 25 - .../runtime-ssh-access-verification.test.ts | 143 ----- .../ssh/runtime-ssh-access-verification.ts | 23 - src/main/ssh/runtime-ssh-access.test.ts | 378 ------------- src/main/ssh/runtime-ssh-access.ts | 249 --------- ...h-connection-store-orcad-ownership.test.ts | 72 --- src/main/ssh/ssh-connection-store.ts | 9 +- src/main/ssh/ssh-connection.ts | 3 +- src/main/ssh/ssh-target-orcad-claims.test.ts | 149 ------ src/main/ssh/ssh-target-orcad-claims.ts | 172 ------ .../ssh-target-orcad-dependents-fixture.ts | 16 - .../ssh/ssh-target-orcad-dependents.test.ts | 136 ----- src/main/ssh/ssh-target-orcad-dependents.ts | 115 ---- src/shared/managed-orcad-ssh-owner.test.ts | 26 - src/shared/managed-orcad-ssh-owner.ts | 29 - src/shared/orcad-managed-runtime.ts | 52 -- src/shared/orcad-migration-preflight.ts | 7 - src/shared/orcad-ssh-provisioning.ts | 20 - ...me-environment-managed-orcad-store.test.ts | 100 ---- ...runtime-environment-managed-orcad-store.ts | 80 --- ...ntime-environment-shipped-store-fixture.ts | 35 -- .../runtime-environment-sidecar.test.ts | 38 +- src/shared/runtime-environment-sidecar.ts | 5 - src/shared/runtime-environment-store.ts | 6 - src/shared/runtime-environments.ts | 31 +- src/shared/runtime-ssh-access.ts | 15 - src/shared/ssh-types.ts | 5 +- 55 files changed, 50 insertions(+), 4605 deletions(-) delete mode 100644 src/main/ipc/orcad-runtime-lifecycle-handlers.test.ts delete mode 100644 src/main/ipc/orcad-runtime-lifecycle-handlers.ts delete mode 100644 src/main/ipc/orcad-ssh-provisioning-handlers.test.ts delete mode 100644 src/main/ipc/orcad-ssh-provisioning-handlers.ts delete mode 100644 src/main/ipc/runtime-environment-managed-tunnel.ts delete mode 100644 src/main/ipc/runtime-ssh-access-handlers.test.ts delete mode 100644 src/main/ipc/runtime-ssh-access-handlers.ts delete mode 100644 src/main/ipc/ssh-target-crud-handlers-ownership.test.ts delete mode 100644 src/main/ssh/orcad-managed-runtime-context.ts delete mode 100644 src/main/ssh/orcad-managed-tunnel-resume.ts delete mode 100644 src/main/ssh/orcad-managed-tunnel.test.ts delete mode 100644 src/main/ssh/orcad-managed-tunnel.ts delete mode 100644 src/main/ssh/orcad-runtime-deployment.test.ts delete mode 100644 src/main/ssh/orcad-runtime-deployment.ts delete mode 100644 src/main/ssh/orcad-runtime-lifecycle.ts delete mode 100644 src/main/ssh/orcad-runtime-status.ts delete mode 100644 src/main/ssh/orcad-ssh-provisioning.test.ts delete mode 100644 src/main/ssh/orcad-ssh-provisioning.ts delete mode 100644 src/main/ssh/orcad-tunneled-pairing.test.ts delete mode 100644 src/main/ssh/orcad-tunneled-pairing.ts delete mode 100644 src/main/ssh/runtime-ssh-access-verification.test.ts delete mode 100644 src/main/ssh/runtime-ssh-access-verification.ts delete mode 100644 src/main/ssh/runtime-ssh-access.test.ts delete mode 100644 src/main/ssh/runtime-ssh-access.ts delete mode 100644 src/main/ssh/ssh-connection-store-orcad-ownership.test.ts delete mode 100644 src/main/ssh/ssh-target-orcad-claims.test.ts delete mode 100644 src/main/ssh/ssh-target-orcad-claims.ts delete mode 100644 src/main/ssh/ssh-target-orcad-dependents-fixture.ts delete mode 100644 src/main/ssh/ssh-target-orcad-dependents.test.ts delete mode 100644 src/main/ssh/ssh-target-orcad-dependents.ts delete mode 100644 src/shared/managed-orcad-ssh-owner.test.ts delete mode 100644 src/shared/managed-orcad-ssh-owner.ts delete mode 100644 src/shared/orcad-managed-runtime.ts delete mode 100644 src/shared/orcad-ssh-provisioning.ts delete mode 100644 src/shared/runtime-environment-managed-orcad-store.test.ts delete mode 100644 src/shared/runtime-environment-managed-orcad-store.ts delete mode 100644 src/shared/runtime-environment-shipped-store-fixture.ts delete mode 100644 src/shared/runtime-ssh-access.ts diff --git a/src/main/ipc/orcad-runtime-lifecycle-handlers.test.ts b/src/main/ipc/orcad-runtime-lifecycle-handlers.test.ts deleted file mode 100644 index cc15058c7f8a..000000000000 --- a/src/main/ipc/orcad-runtime-lifecycle-handlers.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - deploy: vi.fn(), - status: vi.fn(), - registerProvisioning: vi.fn() -})) - -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/orcad-runtime-lifecycle', () => ({ - createManagedOrcadEnvironment: mocks.deploy, - getManagedOrcadRuntimeStatus: mocks.status -})) -vi.mock('./orcad-ssh-provisioning-handlers', () => ({ - registerOrcadSshProvisioningHandlers: mocks.registerProvisioning -})) - -const { registerOrcadRuntimeLifecycleHandlers } = await import('./orcad-runtime-lifecycle-handlers') - -function handler(channel: string): (_event: unknown, args: unknown) => unknown { - const registration = mocks.handle.mock.calls.find(([name]) => name === channel) - if (!registration) { - throw new Error(`${channel} handler was not registered`) - } - return registration[1] -} - -describe('managed orcad lifecycle IPC', () => { - beforeEach(() => { - vi.clearAllMocks() - registerOrcadRuntimeLifecycleHandlers({ getUserDataPath: () => '/profile' }) - }) - - it('registers only deploy, status and provisioning; maintenance and stop are not exposed', () => { - expect(mocks.handle.mock.calls.map(([channel]) => channel)).toEqual([ - 'runtimeEnvironments:deployOrcad', - 'runtimeEnvironments:getOrcadStatus' - ]) - expect(mocks.registerProvisioning).toHaveBeenCalledOnce() - }) - - it('trims deploy input and treats only a literal true as force', async () => { - await handler('runtimeEnvironments:deployOrcad')(null, { - name: ' Managed ', - sshTargetId: ' ssh-1 ', - force: 'yes' - }) - expect(mocks.deploy).toHaveBeenCalledWith('/profile', { - name: 'Managed', - sshTargetId: 'ssh-1', - force: false - }) - }) - - it('rejects missing selectors before touching SSH', async () => { - await expect(handler('runtimeEnvironments:deployOrcad')(null, { name: 'x' })).rejects.toThrow( - 'SSH target is required' - ) - expect(() => handler('runtimeEnvironments:getOrcadStatus')(null, undefined)).toThrow( - 'Server is required' - ) - expect(mocks.deploy).not.toHaveBeenCalled() - expect(mocks.status).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/orcad-runtime-lifecycle-handlers.ts b/src/main/ipc/orcad-runtime-lifecycle-handlers.ts deleted file mode 100644 index 35801d8de779..000000000000 --- a/src/main/ipc/orcad-runtime-lifecycle-handlers.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ipcMain } from 'electron' -import type { OrcadManagedRuntimeStatus } from '../../shared/orcad-managed-runtime' -import { - createManagedOrcadEnvironment, - getManagedOrcadRuntimeStatus -} from '../ssh/orcad-runtime-lifecycle' -import { registerOrcadSshProvisioningHandlers } from './orcad-ssh-provisioning-handlers' - -export function registerOrcadRuntimeLifecycleHandlers(options: { - getUserDataPath: () => string -}): void { - registerOrcadSshProvisioningHandlers(options.getUserDataPath) - ipcMain.handle( - 'runtimeEnvironments:deployOrcad', - async (_event, args: { name: string; sshTargetId: string; force?: boolean }) => - createManagedOrcadEnvironment(options.getUserDataPath(), { - name: requiredString(args?.name, 'Server name'), - sshTargetId: requiredString(args?.sshTargetId, 'SSH target'), - force: args?.force === true - }) - ) - ipcMain.handle( - 'runtimeEnvironments:getOrcadStatus', - (_event, args: { selector: string }): Promise => - getManagedOrcadRuntimeStatus( - options.getUserDataPath(), - requiredString(args?.selector, 'Server') - ) - ) -} - -function requiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || !value.trim()) { - throw new Error(`${label} is required.`) - } - return value.trim() -} diff --git a/src/main/ipc/orcad-ssh-provisioning-handlers.test.ts b/src/main/ipc/orcad-ssh-provisioning-handlers.test.ts deleted file mode 100644 index 6d5fa72da348..000000000000 --- a/src/main/ipc/orcad-ssh-provisioning-handlers.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - create: vi.fn(), - resume: vi.fn(), - list: vi.fn() -})) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/orcad-ssh-provisioning', () => ({ - createOrcadSshHost: mocks.create, - resumeOrcadSshHost: mocks.resume, - listPendingOrcadSshProvisioning: mocks.list -})) -import { registerOrcadSshProvisioningHandlers } from './orcad-ssh-provisioning-handlers' - -describe('managed SSH provisioning IPC', () => { - beforeEach(() => vi.clearAllMocks()) - - it('registers typed create, resume and pending discovery without changing legacy SSH channels', async () => { - registerOrcadSshProvisioningHandlers(() => '/active-profile') - const handlers = new Map(mocks.handle.mock.calls.map(([name, handler]) => [name, handler])) - expect([...handlers.keys()]).toEqual([ - 'runtimeEnvironments:createOrcadSshHost', - 'runtimeEnvironments:resumeOrcadSshHost', - 'runtimeEnvironments:listPendingOrcadSshProvisioning' - ]) - const request = { requestId: 'request-1', name: 'host', target: { host: 'builder' } } - const pending = { result: { outcome: 'pending', reason: 'unverifiable' } } - mocks.create.mockResolvedValue(pending) - expect(await handlers.get('runtimeEnvironments:createOrcadSshHost')!(null, request)).toBe( - pending - ) - expect(mocks.create).toHaveBeenCalledWith('/active-profile', request) - await handlers.get('runtimeEnvironments:resumeOrcadSshHost')!(null, { requestId: 'request-1' }) - expect(mocks.resume).toHaveBeenCalledWith('/active-profile', 'request-1') - handlers.get('runtimeEnvironments:listPendingOrcadSshProvisioning')!() - expect(mocks.list).toHaveBeenCalledWith('/active-profile') - }) -}) diff --git a/src/main/ipc/orcad-ssh-provisioning-handlers.ts b/src/main/ipc/orcad-ssh-provisioning-handlers.ts deleted file mode 100644 index 31cdaeed6c2a..000000000000 --- a/src/main/ipc/orcad-ssh-provisioning-handlers.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ipcMain } from 'electron' -import type { OrcadSshProvisioningRequest } from '../../shared/orcad-ssh-provisioning' -import { - createOrcadSshHost, - listPendingOrcadSshProvisioning, - resumeOrcadSshHost -} from '../ssh/orcad-ssh-provisioning' - -export function registerOrcadSshProvisioningHandlers(getUserDataPath: () => string): void { - ipcMain.handle( - 'runtimeEnvironments:createOrcadSshHost', - (_event, args: OrcadSshProvisioningRequest) => createOrcadSshHost(getUserDataPath(), args) - ) - ipcMain.handle('runtimeEnvironments:resumeOrcadSshHost', (_event, args: { requestId: string }) => - resumeOrcadSshHost(getUserDataPath(), args?.requestId) - ) - ipcMain.handle('runtimeEnvironments:listPendingOrcadSshProvisioning', () => - listPendingOrcadSshProvisioning(getUserDataPath()) - ) -} diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts index de80ea0b1785..23b40fe51834 100644 --- a/src/main/ipc/runtime-environment-handler-channels.ts +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -12,12 +12,5 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:getStatusSnapshots', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', - 'runtimeEnvironments:unsubscribe', - 'runtimeEnvironments:linkSshAccess', - 'runtimeEnvironments:unlinkSshAccess', - 'runtimeEnvironments:deployOrcad', - 'runtimeEnvironments:getOrcadStatus', - 'runtimeEnvironments:createOrcadSshHost', - 'runtimeEnvironments:resumeOrcadSshHost', - 'runtimeEnvironments:listPendingOrcadSshProvisioning' + 'runtimeEnvironments:unsubscribe' ] as const diff --git a/src/main/ipc/runtime-environment-managed-tunnel.ts b/src/main/ipc/runtime-environment-managed-tunnel.ts deleted file mode 100644 index d496dbd82501..000000000000 --- a/src/main/ipc/runtime-environment-managed-tunnel.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import { ensureOrcadManagedTunnel } from '../ssh/orcad-managed-tunnel' - -export async function resolveManagedRuntimeEnvironment( - userDataPath: string, - selector: string -): Promise> { - const environment = resolveEnvironment(userDataPath, selector) - await ensureOrcadManagedTunnel(userDataPath, environment.id) - return resolveEnvironment(userDataPath, environment.id) -} diff --git a/src/main/ipc/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environment-status-owner.ts index 885865779050..ec23ebe47bd6 100644 --- a/src/main/ipc/runtime-environment-status-owner.ts +++ b/src/main/ipc/runtime-environment-status-owner.ts @@ -21,7 +21,6 @@ import { } from './runtime-environment-capability-evidence' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' import { runtimeEnvironmentChangedFailure } from './runtime-environment-revision-guard' -import { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel' export function createRuntimeEnvironmentStatusOwner( userDataPath: string, @@ -42,13 +41,6 @@ export function createRuntimeEnvironmentStatusOwner( const incarnation = getRuntimeEnvironmentCapabilityIncarnation(environment.id) const isCurrent = (): boolean => getRuntimeEnvironmentCapabilityIncarnation(environment.id) === incarnation - if (environment.connectionDependency === 'ssh-tunnel') { - await resolveManagedRuntimeEnvironment(userDataPath, environment.id) - if (!isCurrent()) { - return runtimeEnvironmentChangedFailure(environment, 'status.get') - } - signal.throwIfAborted() - } evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) const response = await (transport.isReady() && getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, pairing, null)?.kind === diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index f67ca33b467f..44430ec4f8f3 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -2,7 +2,6 @@ import { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe' import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' -import { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel' import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' import type { RuntimeOrchestrationEnvelope, @@ -65,10 +64,7 @@ export async function callRuntimeEnvironment( environment.id, method, async () => { - const currentEnvironment = await resolveManagedRuntimeEnvironment( - userDataPath, - environment.id - ) + const currentEnvironment = resolveEnvironment(userDataPath, environment.id) const revisionFailure = runtimeEnvironmentRevisionFailure( currentEnvironment, expectedEnvironmentPairingRevision, @@ -161,7 +157,7 @@ export async function subscribeRuntimeEnvironment( }, isCurrent: () => boolean = () => true ): Promise { - const environment = await resolveManagedRuntimeEnvironment(userDataPath, selector) + const environment = resolveEnvironment(userDataPath, selector) const pairing = getPreferredPairingOffer(environment) const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS let markedUsed = false diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index ba817f3eb82f..6919c68f198a 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -152,13 +152,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:retryConnectionsNow', 'runtimeEnvironments:getStatus', 'runtimeEnvironments:call', - 'runtimeEnvironments:linkSshAccess', - 'runtimeEnvironments:unlinkSshAccess', - 'runtimeEnvironments:createOrcadSshHost', - 'runtimeEnvironments:resumeOrcadSshHost', - 'runtimeEnvironments:listPendingOrcadSshProvisioning', - 'runtimeEnvironments:deployOrcad', - 'runtimeEnvironments:getOrcadStatus', 'runtimeEnvironments:subscribe', 'runtimeEnvironments:unsubscribe' ]) @@ -185,13 +178,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', 'runtimeEnvironments:unsubscribe', - 'runtimeEnvironments:linkSshAccess', - 'runtimeEnvironments:unlinkSshAccess', - 'runtimeEnvironments:deployOrcad', - 'runtimeEnvironments:getOrcadStatus', - 'runtimeEnvironments:createOrcadSshHost', - 'runtimeEnvironments:resumeOrcadSshHost', - 'runtimeEnvironments:listPendingOrcadSshProvisioning', 'runtimeEnvironments:retryConnectionsNow' ]) expect(removeAllListenersMock).toHaveBeenCalledWith('runtimeEnvironments:subscriptionBinary') diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 7e50f4b8f71d..bbb6bd09a657 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -18,8 +18,6 @@ import { registerRuntimeEnvironmentSubscriptions } from './runtime-environment-subscriptions' import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels' -import { registerOrcadRuntimeLifecycleHandlers } from './orcad-runtime-lifecycle-handlers' -import { registerRuntimeSshAccessHandlers } from './runtime-ssh-access-handlers' import { retirePairedRuntimeBrowserClientHostEnvironment } from '../browser/paired-runtime-browser-client-host-runtime' import { registerRuntimeEnvironmentBrowserClientHostHandler } from './runtime-environment-browser-client-host-handler' import { advanceRuntimeEnvironmentCapabilityIncarnation } from './runtime-environment-capability-evidence' @@ -69,10 +67,5 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id).activate() } } - registerRuntimeSshAccessHandlers({ - getUserDataPath, - invalidateTransport: invalidateRuntimeEnvironmentTransport - }) - registerOrcadRuntimeLifecycleHandlers({ getUserDataPath }) registerRuntimeEnvironmentSubscriptions(getUserDataPath) } diff --git a/src/main/ipc/runtime-ssh-access-handlers.test.ts b/src/main/ipc/runtime-ssh-access-handlers.test.ts deleted file mode 100644 index ecf5d2de21e4..000000000000 --- a/src/main/ipc/runtime-ssh-access-handlers.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ handle: vi.fn(), link: vi.fn(), unlink: vi.fn() })) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/runtime-ssh-access', () => ({ - linkRuntimeSshAccess: mocks.link, - unlinkRuntimeSshAccess: mocks.unlink -})) -import { registerRuntimeSshAccessHandlers } from './runtime-ssh-access-handlers' - -describe('existing paired server SSH access IPC', () => { - const invalidateTransport = vi.fn() - const request = { - selector: 'host', - requestId: 'request-1', - sshTargetId: 'ssh-host', - remotePort: 6768 - } - beforeEach(() => { - vi.clearAllMocks() - registerRuntimeSshAccessHandlers({ - getUserDataPath: () => '/test-profile', - invalidateTransport - }) - }) - function handler(channel: string): (_event: null, input: unknown) => Promise { - const registered = mocks.handle.mock.calls.find(([name]) => name === channel) - if (!registered) { - throw new Error('Handler missing') - } - return registered[1] - } - - it('registers access-only actions and forwards only validated arguments and main-owned invalidation', async () => { - expect(mocks.handle.mock.calls.map(([name]) => name)).toEqual([ - 'runtimeEnvironments:linkSshAccess', - 'runtimeEnvironments:unlinkSshAccess' - ]) - const result = { - id: 'host', - endpoints: [{ id: 'ssh-endpoint', endpoint: 'ws://127.0.0.1:41000' }] - } - mocks.link.mockResolvedValue(result) - expect(await handler('runtimeEnvironments:linkSshAccess')(null, request)).toBe(result) - expect(mocks.link).toHaveBeenCalledExactlyOnceWith('/test-profile', request, { - invalidateTransport - }) - await handler('runtimeEnvironments:unlinkSshAccess')(null, { - selector: 'host', - requestId: 'unlink-1' - }) - expect(mocks.unlink).toHaveBeenCalledExactlyOnceWith( - '/test-profile', - { selector: 'host', requestId: 'unlink-1' }, - { invalidateTransport } - ) - }) - - it.each([ - { remotePort: 0 }, - { remotePort: 65536 }, - { requestId: '../request' }, - { verifiedRuntimeId: 'renderer-supplied' }, - { userDataPath: '/other-profile' }, - { owner: { type: 'orcad-runtime', environmentId: 'other' } } - ])('refuses invalid or authority-bearing link arguments: %j', async (change) => { - await expect( - handler('runtimeEnvironments:linkSshAccess')(null, { ...request, ...change }) - ).rejects.toThrow() - expect(mocks.link).not.toHaveBeenCalled() - }) - - it('refuses renderer-supplied unlink snapshots', async () => { - await expect( - handler('runtimeEnvironments:unlinkSshAccess')(null, { - selector: 'host', - requestId: 'unlink-1', - expectedEnvironment: { id: 'other' } - }) - ).rejects.toThrow() - expect(mocks.unlink).not.toHaveBeenCalled() - }) - - it('propagates pending failures instead of reporting a successful link', async () => { - const error = new Error('SSH endpoint identity was not verified') - mocks.link.mockRejectedValue(error) - await expect(handler('runtimeEnvironments:linkSshAccess')(null, request)).rejects.toBe(error) - }) -}) diff --git a/src/main/ipc/runtime-ssh-access-handlers.ts b/src/main/ipc/runtime-ssh-access-handlers.ts deleted file mode 100644 index deed2936f356..000000000000 --- a/src/main/ipc/runtime-ssh-access-handlers.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ipcMain } from 'electron' -import { - RuntimeSshAccessLinkRequestSchema, - RuntimeSshAccessUnlinkRequestSchema -} from '../../shared/runtime-ssh-access' -import { linkRuntimeSshAccess, unlinkRuntimeSshAccess } from '../ssh/runtime-ssh-access' - -export function registerRuntimeSshAccessHandlers(options: { - getUserDataPath: () => string - invalidateTransport: (environmentId: string) => void | Promise -}): void { - ipcMain.handle('runtimeEnvironments:linkSshAccess', async (_event, input: unknown) => { - const args = RuntimeSshAccessLinkRequestSchema.parse(input) - return linkRuntimeSshAccess(options.getUserDataPath(), args, { - invalidateTransport: options.invalidateTransport - }) - }) - ipcMain.handle('runtimeEnvironments:unlinkSshAccess', async (_event, input: unknown) => { - const args = RuntimeSshAccessUnlinkRequestSchema.parse(input) - return unlinkRuntimeSshAccess(options.getUserDataPath(), args, { - invalidateTransport: options.invalidateTransport - }) - }) -} diff --git a/src/main/ipc/ssh-host-sleep-reconnect.test.ts b/src/main/ipc/ssh-host-sleep-reconnect.test.ts index e0f9721e6e03..1994e8c1a0ef 100644 --- a/src/main/ipc/ssh-host-sleep-reconnect.test.ts +++ b/src/main/ipc/ssh-host-sleep-reconnect.test.ts @@ -4,16 +4,12 @@ const manager = vi.hoisted(() => { const connection = {} return { getConnection: vi.fn(() => connection), reconnect: vi.fn(async () => {}) } }) -const recoverManagedTunnels = vi.hoisted(() => vi.fn(async () => {})) vi.mock('electron', async () => { const { EventEmitter } = await import('node:events') return { powerMonitor: new EventEmitter() } }) vi.mock('./ssh-ipc-context', () => ({ connectionManager: manager })) -vi.mock('../ssh/orcad-managed-tunnel', () => ({ - recoverOrcadManagedTunnelsAfterHostResume: recoverManagedTunnels -})) import { powerMonitor } from 'electron' import { activeSessions } from './ssh-active-relay-sessions' @@ -60,24 +56,4 @@ describe('host sleep reconnect in plain SSH mode', () => { powerMonitor.emit('resume') await vi.waitFor(() => expect(manager.reconnect).toHaveBeenCalledWith('target-1')) }) - - it('recovers managed tunnels with the same probe policy, even with no relay sessions', async () => { - registerPowerMonitorReconnect(() => '/user-data') - powerMonitor.emit('resume') - await vi.waitFor(() => - expect(recoverManagedTunnels).toHaveBeenCalledWith('/user-data', { - attempts: 2, - timeoutMs: 5_000 - }) - ) - }) - - it('leaves managed tunnels alone when the caller supplies no profile path', async () => { - const session = plainSession(async () => true) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume path only calls the methods stubbed here. - activeSessions.set('target-1', session as never) - registerPowerMonitorReconnect() - await resumeAndSettle() - expect(recoverManagedTunnels).not.toHaveBeenCalled() - }) }) diff --git a/src/main/ipc/ssh-host-sleep-reconnect.ts b/src/main/ipc/ssh-host-sleep-reconnect.ts index 3c559059f45c..731946e3cfcb 100644 --- a/src/main/ipc/ssh-host-sleep-reconnect.ts +++ b/src/main/ipc/ssh-host-sleep-reconnect.ts @@ -1,5 +1,4 @@ import { powerMonitor } from 'electron' -import { recoverOrcadManagedTunnelsAfterHostResume } from '../ssh/orcad-managed-tunnel' import type { SshRelaySession } from '../ssh/ssh-relay-session' import { activeSessions } from './ssh-active-relay-sessions' import { connectionManager } from './ssh-ipc-context' @@ -33,7 +32,7 @@ async function isRelayLinkAliveAfterResume(session: SshRelaySession): Promise string): void { +export function registerPowerMonitorReconnect(): void { powerMonitorUnsubscribe?.() const onSuspend = (): void => { for (const session of activeSessions.values()) { @@ -67,19 +66,6 @@ export function registerPowerMonitorReconnect(getUserDataPath?: () => string): v } })() } - // Why separate: managed tunnels ride their own connections, not relay sessions. - if (getUserDataPath) { - void recoverOrcadManagedTunnelsAfterHostResume(getUserDataPath(), { - attempts: RESUME_PROBE_ATTEMPTS, - timeoutMs: RESUME_PROBE_TIMEOUT_MS - }).catch((err) => { - console.warn( - `[ssh] Failed to recover a managed Orca tunnel after system resume: ${ - err instanceof Error ? err.message : String(err) - }` - ) - }) - } } powerMonitor.on('suspend', onSuspend) powerMonitor.on('resume', onResume) diff --git a/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts b/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts deleted file mode 100644 index 522e74876196..000000000000 --- a/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import type { SshTarget } from '../../shared/ssh-types' - -const mocks = vi.hoisted(() => { - const state: { target?: SshTarget } = {} - return { handle: vi.fn(), remove: vi.fn(), state, addTarget: vi.fn(), updateTarget: vi.fn() } -}) - -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/ssh-target-registry', () => ({ - getSshTargetRegistryStore: () => ({ - getTarget: () => mocks.state.target, - addTarget: mocks.addTarget, - updateTarget: mocks.updateTarget, - lastRepoReadoptions: [] - }) -})) -vi.mock('./ssh-session-teardown', () => ({ removeRegisteredSshTarget: mocks.remove })) -vi.mock('./ssh-ipc-context', () => ({ getCurrentMainWindow: () => null })) - -const { registerSshTargetCrudHandlers } = await import('./ssh-target-crud-handlers') - -function handler(channel: string): (_event: unknown, args: unknown) => unknown { - const registration = mocks.handle.mock.calls.find(([name]) => name === channel) - if (!registration) { - throw new Error(`${channel} handler was not registered`) - } - return registration[1] -} - -const target: SshTarget = { id: 'ssh-1', label: 'host', host: 'host', port: 22, username: 'dev' } - -describe('SSH target CRUD against managed orcad targets', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.state.target = { ...target, owner: createManagedOrcadSshOwner('environment-1') } - registerSshTargetCrudHandlers() - }) - - it('refuses to edit or remove a target a managed server owns', async () => { - expect(() => - handler('ssh:updateTarget')(null, { id: 'ssh-1', updates: { host: 'elsewhere' } }) - ).toThrow('cannot be edited') - await expect(handler('ssh:removeTarget')(null, { id: 'ssh-1' })).rejects.toThrow( - 'cannot be removed' - ) - expect(mocks.updateTarget).not.toHaveBeenCalled() - expect(mocks.remove).not.toHaveBeenCalled() - }) - - it('refuses a target with a pending provisioning request too', () => { - mocks.state.target = { - ...target, - orcadProvisioning: { requestId: 'request-1', name: 'Managed' } - } - expect(() => handler('ssh:updateTarget')(null, { id: 'ssh-1', updates: {} })).toThrow( - 'cannot be edited' - ) - }) - - it('never lets the renderer write a provisioning intent', () => { - mocks.state.target = target - handler('ssh:updateTarget')(null, { - id: 'ssh-1', - updates: { label: 'renamed', orcadProvisioning: { requestId: 'x', name: 'y' } } - }) - handler('ssh:addTarget')(null, { - target: { ...target, orcadProvisioning: { requestId: 'x', name: 'y' } } - }) - expect(mocks.updateTarget).toHaveBeenCalledWith('ssh-1', { label: 'renamed' }) - expect(mocks.addTarget.mock.calls[0]?.[0]).not.toHaveProperty('orcadProvisioning') - }) -}) diff --git a/src/main/ipc/ssh-target-crud-handlers.ts b/src/main/ipc/ssh-target-crud-handlers.ts index 3a045f32107a..e8b7a95c4784 100644 --- a/src/main/ipc/ssh-target-crud-handlers.ts +++ b/src/main/ipc/ssh-target-crud-handlers.ts @@ -11,7 +11,6 @@ import { } from '../ssh/ssh-config-host-picker' import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store' import { getCurrentMainWindow } from './ssh-ipc-context' import { removeRegisteredSshTarget } from './ssh-session-teardown' @@ -35,26 +34,18 @@ function takeRepoReadoptions(): SshRepoReadoption[] { return repoReadoptions } -// Why: generations, provisioning and the runtime ladder cache are main-owned; a renderer must not forge them. +// Why: generations and the runtime ladder cache are main-owned; a renderer must not forge them. function omitRendererSshTargetGeneration< - T extends { generation?: unknown; orcadProvisioning?: unknown; remoteRuntimeResolution?: unknown } ->(value: T): Omit { + T extends { generation?: unknown; remoteRuntimeResolution?: unknown } +>(value: T): Omit { const { generation: _generation, - orcadProvisioning: _orcadProvisioning, remoteRuntimeResolution: _remoteRuntimeResolution, ...rest } = value return rest } -function assertNotRuntimeOwned(targetId: string, action: string): void { - const target = getSshTargetRegistryStore()!.getTarget(targetId) - if (target && isRuntimeOwnedSshTarget(target)) { - throw new Error(`Managed runtime SSH targets cannot be ${action} from SSH settings.`) - } -} - export function registerSshTargetCrudHandlers(): void { ipcMain.handle('ssh:listTargets', () => { return getSshTargetRegistryStore()!.listTargets() @@ -76,7 +67,6 @@ export function registerSshTargetCrudHandlers(): void { ipcMain.handle( 'ssh:updateTarget', (_event, args: { id: string; updates: SshTargetUpdateInput }) => { - assertNotRuntimeOwned(args.id, 'edited') return getSshTargetRegistryStore()!.updateTarget( args.id, omitRendererSshTargetGeneration(args.updates) @@ -85,7 +75,6 @@ export function registerSshTargetCrudHandlers(): void { ) ipcMain.handle('ssh:removeTarget', async (_event, args: { id: string }) => { - assertNotRuntimeOwned(args.id, 'removed') await removeRegisteredSshTarget(args.id) }) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 607f16bb3543..9ccf4ad16f6b 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -77,8 +77,6 @@ import { broadcastPortForwards, relayStateOverrides } from './ssh-renderer-broad import { resetSshShutdownDrain } from './ssh-shutdown-drain' import { registerSshTargetCrudHandlers } from './ssh-target-crud-handlers' import { targetLifecycleInFlight } from './ssh-target-lifecycle-queue' -import { disposeOrcadManagedTunnels } from '../ssh/orcad-managed-tunnel' -import { getAppEnvironment } from '../../shared/app-environment' const SSH_IPC_CHANNELS = [ 'ssh:listTargets', @@ -211,7 +209,7 @@ export function registerSshHandlers( } }) refreshActiveRelaySessions() - registerPowerMonitorReconnect(() => getAppEnvironment().getPath('userData')) + registerPowerMonitorReconnect() registerSshBrowseHandler(() => connectionManager) setSshConnectionManagerResolver(() => connectionManager) @@ -255,7 +253,6 @@ export async function resetSshHandlerStateForTests(): Promise { resetSshShutdownDrain() await connectionManager?.disconnectAll() - disposeOrcadManagedTunnels() portForwardManager?.dispose() setConnectionManager(null) setSshConnectionManagerResolver(null) diff --git a/src/main/ssh/orcad-managed-runtime-context.ts b/src/main/ssh/orcad-managed-runtime-context.ts deleted file mode 100644 index 55a29be73986..000000000000 --- a/src/main/ssh/orcad-managed-runtime-context.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import type { - KnownRuntimeEnvironment, - OrcadDeploymentLink -} from '../../shared/runtime-environments' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { ServeReadiness } from '../server/serve-readiness' -import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' -import { probeActiveOrcadReadiness } from './orcad-active-readiness' -import { resolveOrcadRemoteContext, type OrcadRemoteContext } from './orcad-remote-context' -import { ORCAD_INSTALL_MODEL } from './remote-install-model' -import { computeRemoteInstallDir } from './ssh-relay-versioned-install' -import { getSshConnectionManager, getSshTargetRegistryStore } from './ssh-target-registry' - -export const ORCAD_BIND_HOST = '127.0.0.1' - -// Why empty: managed slots always carry their pinned runtime marker, so a marker-less slot -// fails to launch instead of silently running on whatever Node the host has. -export const MANAGED_ORCAD_LEGACY_NODE_PATH = '' - -export function requireManagedOrcadInfrastructure() { - const targetStore = requireManagedOrcadTargetStore() - const connectionManager = getSshConnectionManager() - if (!connectionManager) { - throw new Error('SSH is unavailable on this client; the managed Orca server is unverifiable.') - } - return { connectionManager, targetStore, claims: targetStore.getOrcadRuntimeClaims() } -} - -export function requireManagedOrcadTargetStore() { - const targetStore = getSshTargetRegistryStore() - if (!targetStore) { - throw new Error('SSH target state is unavailable; the managed Orca server is unverifiable.') - } - return targetStore -} - -export function requireManagedOrcadEnvironment( - userDataPath: string, - selector: string -): { environment: KnownRuntimeEnvironment; deployment: OrcadDeploymentLink } { - const environment = resolveEnvironment(userDataPath, selector) - const deployment = environment.orcadDeployment - if (!deployment || environment.connectionDependency !== 'ssh-tunnel') { - throw new Error('This server is not managed through an orcad SSH deployment.') - } - return { environment, deployment } -} - -export async function resolveLinkedOrcadContext( - environment: KnownRuntimeEnvironment, - deployment: OrcadDeploymentLink, - signal?: AbortSignal -): Promise { - const { connectionManager, targetStore } = requireManagedOrcadInfrastructure() - const target = targetStore.getTarget(deployment.sshTargetId) - if ( - !target || - target.generation !== deployment.sshTargetGeneration || - getManagedOrcadOwnerEnvironmentId(target.owner) !== environment.id - ) { - throw new Error('The managed Orca server SSH registration is no longer valid.') - } - const connection = await connectionManager.connect(target) - return resolveOrcadRemoteContext(target, connection, signal) -} - -export function managedOrcadInstallDir(context: OrcadRemoteContext, version: string): string { - return computeRemoteInstallDir( - ORCAD_INSTALL_MODEL, - context.remoteHome, - version, - context.host.pathFlavor - ) -} - -/** The active slot's readiness, accepted only if it is the build this client holds. */ -export function probeManagedOrcadReadiness( - context: OrcadRemoteContext, - localOrcadDir: string, - fullVersion: string, - signal?: AbortSignal -): Promise { - return probeActiveOrcadReadiness( - { - conn: context.connection, - host: context.host, - remoteInstallDir: managedOrcadInstallDir(context, fullVersion), - signal - }, - { buildHash: computeLocalOrcadBuildHash(localOrcadDir), fullVersion } - ) -} - -// Why only this code: deploy never has a session count to force past, so forcing an unknown -// census lands on the daemon-protocol deferral, which force cannot clear. -export function isForceableOrcadDeferral(code: string): boolean { - return code === 'orcad_update_terminals_running' -} diff --git a/src/main/ssh/orcad-managed-tunnel-resume.ts b/src/main/ssh/orcad-managed-tunnel-resume.ts deleted file mode 100644 index 4f76704ed3cf..000000000000 --- a/src/main/ssh/orcad-managed-tunnel-resume.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import { - getPreferredPairingOffer, - getRuntimeSshAccess, - type KnownRuntimeEnvironment, - type RuntimeSshTunnelLink -} from '../../shared/runtime-environments' -import type { SshConnection } from './ssh-connection' -import type { SshConnectionManager } from './ssh-connection-manager' -import type { SshPortForwardManager } from './ssh-port-forward' -import type { getSshTargetRegistryStore } from './ssh-target-registry' - -export type ActiveOrcadTunnel = { - connection: SshConnection - forwardId: string - localPort: number - remotePort: number - sshTargetGeneration: number - targetId: string - transportGeneration: number -} - -export type OrcadManagedTunnelProbe = ( - environment: KnownRuntimeEnvironment, - timeoutMs: number -) => Promise - -export type OrcadManagedTunnelResumeOptions = { - attempts: number - resolveEnvironment: (environmentId: string) => KnownRuntimeEnvironment | null - timeoutMs: number -} - -type ResumeRecoveryDependencies = { - active: Map - forwards: SshPortForwardManager - getConnectionManager: () => SshConnectionManager | null - getManagerGeneration: () => number - getTargetStore: () => ReturnType - inFlight: Map> - ownershipGenerations: Map - probeTunnel?: OrcadManagedTunnelProbe -} - -type ResolvedManagedTunnelEnvironment = { - deployment: RuntimeSshTunnelLink - environment: KnownRuntimeEnvironment -} - -export class OrcadManagedTunnelResumeRecovery { - private readonly probeTunnel: OrcadManagedTunnelProbe - private resumeInFlight: Promise | null = null - - constructor(private readonly dependencies: ResumeRecoveryDependencies) { - this.probeTunnel = dependencies.probeTunnel ?? probeManagedOrcadTunnel - } - - dispose(): void { - this.resumeInFlight = null - } - - recover(options: OrcadManagedTunnelResumeOptions): Promise { - if (this.resumeInFlight) { - return this.resumeInFlight - } - const managerGeneration = this.dependencies.getManagerGeneration() - const recoveries = [...this.dependencies.active].map(([environmentId, active]) => - this.recoverActive(environmentId, active, managerGeneration, options) - ) - const operation = Promise.allSettled(recoveries) - .then((results) => { - const failed = results.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - if (failed) { - throw failed.reason - } - }) - .finally(() => { - if (this.resumeInFlight === operation) { - this.resumeInFlight = null - } - }) - this.resumeInFlight = operation - return operation - } - - private async recoverActive( - environmentId: string, - active: ActiveOrcadTunnel, - managerGeneration: number, - options: OrcadManagedTunnelResumeOptions - ): Promise { - const ownershipGeneration = this.dependencies.ownershipGenerations.get(environmentId) ?? 0 - for (let attempt = 0; attempt < options.attempts; attempt++) { - const resolved = this.resolveEnvironment(environmentId, active, options) - if (!resolved) { - return - } - if (await this.probeTunnel(resolved.environment, options.timeoutMs)) { - return - } - if (!this.stillOwned(environmentId, active, ownershipGeneration, managerGeneration, true)) { - return - } - } - - const pending = this.dependencies.inFlight.get(environmentId) - if (pending) { - await pending.catch(() => undefined) - } - if (!this.stillOwned(environmentId, active, ownershipGeneration, managerGeneration, true)) { - return - } - const resolved = this.resolveEnvironment(environmentId, active, options) - const connectionManager = this.dependencies.getConnectionManager() - if ( - !resolved || - !connectionManager || - connectionManager.getConnection(active.targetId) !== active.connection - ) { - return - } - const operation = this.reconnectAndRebuild( - resolved.environment, - active, - connectionManager, - ownershipGeneration, - managerGeneration, - options - ).finally(() => { - if (this.dependencies.inFlight.get(environmentId) === operation) { - this.dependencies.inFlight.delete(environmentId) - } - }) - this.dependencies.inFlight.set(environmentId, operation) - await operation - } - - private async reconnectAndRebuild( - environment: KnownRuntimeEnvironment, - active: ActiveOrcadTunnel, - connectionManager: SshConnectionManager, - ownershipGeneration: number, - managerGeneration: number, - options: OrcadManagedTunnelResumeOptions - ): Promise { - await connectionManager.reconnect(active.targetId) - if ( - !this.stillOwned(environment.id, active, ownershipGeneration, managerGeneration, true) || - connectionManager !== this.dependencies.getConnectionManager() || - connectionManager.getConnection(active.targetId) !== active.connection || - connectionManager.getState(active.targetId)?.status !== 'connected' || - active.connection.getTransportGeneration() <= active.transportGeneration - ) { - return - } - if (!this.resolveEnvironment(environment.id, active, options)) { - return - } - - const currentActive = this.dependencies.active.get(environment.id) - if (currentActive && currentActive !== active) { - return - } - if (currentActive === active) { - await this.dependencies.forwards.removeForwardAndWait(active.forwardId) - if (this.dependencies.active.get(environment.id) === active) { - this.dependencies.active.delete(environment.id) - } - } - if (!this.stillOwned(environment.id, active, ownershipGeneration, managerGeneration, true)) { - return - } - - const current = this.resolveEnvironment(environment.id, active, options) - if (!current) { - return - } - const { deployment } = current - const transportGeneration = active.connection.getTransportGeneration() - const forward = await this.dependencies.forwards.addForward( - active.targetId, - active.connection, - deployment.localPort, - '127.0.0.1', - deployment.remotePort, - `Managed Orca server: ${current.environment.name}` - ) - if ( - forward.localPort !== deployment.localPort || - active.connection.getTransportGeneration() !== transportGeneration || - !this.stillOwned(environment.id, active, ownershipGeneration, managerGeneration, true) || - !this.resolveEnvironment(environment.id, active, options) - ) { - await this.dependencies.forwards.removeForwardAndWait(forward.id) - if (forward.localPort !== deployment.localPort) { - throw new Error('Managed Orca tunnel bound an unexpected local port after host resume.') - } - return - } - this.dependencies.active.set(environment.id, { - connection: active.connection, - forwardId: forward.id, - localPort: forward.localPort, - remotePort: forward.remotePort, - sshTargetGeneration: active.sshTargetGeneration, - targetId: active.targetId, - transportGeneration - }) - } - - private resolveEnvironment( - environmentId: string, - active: ActiveOrcadTunnel, - options: OrcadManagedTunnelResumeOptions - ): ResolvedManagedTunnelEnvironment | null { - const environment = options.resolveEnvironment(environmentId) - const deployment = environment ? getRuntimeSshAccess(environment) : undefined - const target = this.dependencies.getTargetStore()?.getTarget(active.targetId) - if ( - !environment || - environment.connectionDependency !== 'ssh-tunnel' || - !deployment || - deployment.sshTargetId !== active.targetId || - deployment.sshTargetGeneration !== active.sshTargetGeneration || - deployment.localPort !== active.localPort || - deployment.remotePort !== active.remotePort || - !target || - target.generation !== active.sshTargetGeneration || - getManagedOrcadOwnerEnvironmentId(target.owner) !== environmentId - ) { - return null - } - return { deployment, environment } - } - - private stillOwned( - environmentId: string, - active: ActiveOrcadTunnel, - ownershipGeneration: number, - managerGeneration: number, - allowMissingActive = false - ): boolean { - const current = this.dependencies.active.get(environmentId) - return ( - this.dependencies.getManagerGeneration() === managerGeneration && - (this.dependencies.ownershipGenerations.get(environmentId) ?? 0) === ownershipGeneration && - (current === active || (allowMissingActive && current === undefined)) - ) - } -} - -async function probeManagedOrcadTunnel( - environment: KnownRuntimeEnvironment, - timeoutMs: number -): Promise { - try { - await sendRemoteRuntimeRequest( - getPreferredPairingOffer(environment), - 'status.get', - undefined, - timeoutMs - ) - return true - } catch { - return false - } -} diff --git a/src/main/ssh/orcad-managed-tunnel.test.ts b/src/main/ssh/orcad-managed-tunnel.test.ts deleted file mode 100644 index 420c04cfccff..000000000000 --- a/src/main/ssh/orcad-managed-tunnel.test.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - createEnvironmentFromPairingOffer, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { PAIRING_OFFER_VERSION } from '../../shared/pairing' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import type { SshTarget } from '../../shared/ssh-types' -import type { SshConnection } from './ssh-connection' -import type { SshConnectionManager } from './ssh-connection-manager' -import type { SshConnectionStore } from './ssh-connection-store' -import type { SshPortForwardManager } from './ssh-port-forward' -import { OrcadManagedTunnelManager } from './orcad-managed-tunnel' - -function createEnvironment(linkKind: 'orcadDeployment' | 'sshAccess'): KnownRuntimeEnvironment { - const paired = createEnvironmentFromPairingOffer({ - id: 'environment-1', - name: 'Managed server', - now: 1, - offer: { - v: PAIRING_OFFER_VERSION, - endpoint: 'ws://127.0.0.1:46768', - deviceToken: 'device-token', - publicKeyB64: 'public-key' - }, - connectionDependency: 'ssh-tunnel' - }) - const access = { - sshTargetId: 'ssh-1', - sshTargetGeneration: 7, - localPort: 46_768, - remotePort: 6_768 - } - return linkKind === 'orcadDeployment' - ? { ...paired, orcadDeployment: access } - : { - ...paired, - sshAccess: { - ...access, - endpointId: paired.preferredEndpointId, - previousPreferredEndpointId: paired.preferredEndpointId - } - } -} - -function setup(overrides: Partial = {}) { - const target: SshTarget = { - id: 'ssh-1', - label: 'Managed server', - host: 'example.com', - port: 22, - username: 'deploy', - generation: 7, - owner: createManagedOrcadSshOwner('environment-1'), - ...overrides - } - let transportGeneration = 3 - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double for the members the tunnel manager calls. - const connection = { - getTransportGeneration: vi.fn(() => transportGeneration) - } as unknown as SshConnection - const connect = vi.fn().mockResolvedValue(connection) - const reconnect = vi.fn().mockImplementation(async () => { - transportGeneration += 1 - }) - const getConnection = vi.fn(() => connection) - const getState = vi.fn(() => ({ status: 'connected' })) - const probeTunnel = vi.fn().mockResolvedValue(true) - const addForward = vi - .fn() - .mockImplementation( - async ( - connectionId: string, - _connection: SshConnection, - localPort: number, - _remoteHost: string, - remotePort: number - ) => ({ - id: `forward-${addForward.mock.calls.length}`, - connectionId, - localPort, - remoteHost: '127.0.0.1', - remotePort - }) - ) - const removeForwardAndWait = vi.fn().mockResolvedValue(null) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double for the members the tunnel manager calls. - const forwardManager = { - setCallbacks: vi.fn(), - addForward, - removeForwardAndWait, - dispose: vi.fn() - } as unknown as SshPortForwardManager - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double for the members the tunnel manager calls. - const connectionManager = { - connect, - getConnection, - getState, - reconnect - } as unknown as SshConnectionManager - const manager = new OrcadManagedTunnelManager({ - getConnectionManager: () => connectionManager, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the tunnel manager reads only getTarget. - getTargetStore: () => ({ getTarget: vi.fn(() => target) }) as unknown as SshConnectionStore, - forwardManager, - probeTunnel - }) - return { - addForward, - connect, - connection, - getConnection, - getState, - manager, - probeTunnel, - reconnect, - removeForwardAndWait, - target, - setTransportGeneration: (generation: number) => { - transportGeneration = generation - } - } -} - -describe('OrcadManagedTunnelManager initial binding', () => { - it.each(['close', 'dispose', 'reconnect'] as const)( - 'removes a late initial forward after %s supersedes setup', - async (action) => { - const state = setup() - state.addForward.mockImplementationOnce(async () => { - if (action === 'close') { - await state.manager.close('environment-1') - } - if (action === 'dispose') { - state.manager.dispose() - } - if (action === 'reconnect') { - state.setTransportGeneration(4) - } - return { id: 'late-initial-forward', localPort: 46_768, remotePort: 6_768 } - }) - - await expect( - state.manager.start('environment-1', state.target, state.connection, 6_768) - ).rejects.toThrow('superseded') - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('late-initial-forward') - await state.manager.close('environment-1') - expect(state.removeForwardAndWait).toHaveBeenCalledTimes(1) - } - ) - - it('does not open a forward when canceled while the prior forward is closing', async () => { - const state = setup() - await state.manager.ensure(createEnvironment('sshAccess')) - state.removeForwardAndWait.mockImplementationOnce(async () => { - await state.manager.close('environment-1') - }) - - await expect( - state.manager.start('environment-1', state.target, state.connection, 6_768) - ).rejects.toThrow('superseded') - - expect(state.addForward).toHaveBeenCalledTimes(1) - }) - - it('discards an older binding without closing the newer tunnel', async () => { - const state = setup() - let finishFirst!: () => void - state.addForward.mockImplementationOnce( - () => - new Promise((resolve) => { - finishFirst = () => resolve({ id: 'old-forward', localPort: 46_768, remotePort: 6_768 }) - }) - ) - const first = state.manager.start('environment-1', state.target, state.connection, 6_768) - const rejected = expect(first).rejects.toThrow('superseded') - await vi.waitFor(() => expect(state.addForward).toHaveBeenCalledOnce()) - - await state.manager.start('environment-1', state.target, state.connection, 6_768) - finishFirst() - await rejected - - expect(state.removeForwardAndWait.mock.calls).toEqual([['old-forward']]) - await state.manager.close('environment-1') - expect(state.removeForwardAndWait.mock.calls).toEqual([['old-forward'], ['forward-2']]) - }) -}) - -describe.each(['orcadDeployment', 'sshAccess'] as const)( - 'OrcadManagedTunnelManager (%s)', - (linkKind) => { - const environment = () => createEnvironment(linkKind) - const resumeOptions = () => ({ - attempts: 2, - resolveEnvironment: () => environment(), - timeoutMs: 5_000 - }) - it('connects through the raw SSH manager and creates the exact loopback forward', async () => { - const state = setup() - - await state.manager.ensure(environment()) - - expect(state.connect).toHaveBeenCalledOnce() - if (linkKind === 'sshAccess') { - expect(environment().orcadDeployment).toBeUndefined() - } - expect(state.addForward).toHaveBeenCalledWith( - 'ssh-1', - expect.anything(), - 46_768, - '127.0.0.1', - 6_768, - 'Managed Orca server: Managed server' - ) - }) - - it('reuses a tunnel only for the same SSH transport generation', async () => { - const state = setup() - - await state.manager.ensure(environment()) - await state.manager.ensure(environment()) - expect(state.addForward).toHaveBeenCalledOnce() - - state.setTransportGeneration(4) - await state.manager.ensure(environment()) - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('forward-1') - expect(state.addForward).toHaveBeenCalledTimes(2) - }) - - it.each(['ensure', 'resume'] as const)( - 'discards a forward when SSH reconnects during %s binding', - async (operation) => { - const state = setup() - if (operation === 'resume') { - await state.manager.ensure(environment()) - state.probeTunnel.mockResolvedValue(false) - } - state.addForward.mockImplementationOnce(async () => { - state.setTransportGeneration(9) - return { id: 'stale-forward', localPort: 46_768, remotePort: 6_768 } - }) - await (operation === 'resume' - ? state.manager.recoverAfterHostResume(resumeOptions()) - : state.manager.ensure(environment())) - expect(state.removeForwardAndWait).toHaveBeenCalledWith('stale-forward') - state.probeTunnel.mockClear() - await state.manager.recoverAfterHostResume(resumeOptions()) - expect(state.probeTunnel).not.toHaveBeenCalled() - await state.manager.ensure(environment()) - expect(state.addForward).toHaveBeenCalledTimes(operation === 'resume' ? 3 : 2) - } - ) - - it('fails closed when the SSH registration generation changed', async () => { - const state = setup({ generation: 8 }) - - await expect(state.manager.ensure(environment())).rejects.toThrow('removed or re-created') - expect(state.connect).not.toHaveBeenCalled() - }) - - it('fails closed when another environment owns the target', async () => { - const state = setup({ owner: createManagedOrcadSshOwner('environment-2') }) - - await expect(state.manager.ensure(environment())).rejects.toThrow('no longer owned') - expect(state.connect).not.toHaveBeenCalled() - }) - - it('coalesces concurrent tunnel preflights', async () => { - const state = setup() - let finishConnect!: () => void - state.connect.mockImplementationOnce( - () => - new Promise((resolve) => { - finishConnect = () => resolve(state.connection) - }) - ) - - const first = state.manager.ensure(environment()) - const second = state.manager.ensure(environment()) - finishConnect() - await Promise.all([first, second]) - - expect(state.connect).toHaveBeenCalledOnce() - expect(state.addForward).toHaveBeenCalledOnce() - }) - - it('does not create a forward after close cancels an in-flight connection', async () => { - const state = setup() - state.connect.mockImplementationOnce(async () => { - await state.manager.close('environment-1') - return state.connection - }) - - await state.manager.ensure(environment()) - - expect(state.addForward).not.toHaveBeenCalled() - }) - - it('rechecks SSH ownership after connection resolves', async () => { - const state = setup() - state.connect.mockImplementationOnce(async () => { - state.target.owner = createManagedOrcadSshOwner('environment-2') - return state.connection - }) - - await state.manager.ensure(environment()) - - expect(state.addForward).not.toHaveBeenCalled() - }) - - it('re-reads the saved environment after connection rather than trusting its initial snapshot', async () => { - const state = setup() - const original = environment() - let current = original - state.connect.mockImplementationOnce(async () => { - current = { - ...original, - pairingRevision: (original.pairingRevision ?? original.createdAt) + 1 - } - return state.connection - }) - - await state.manager.ensure(original, () => current) - - expect(state.addForward).not.toHaveBeenCalled() - }) - - it('removes a newly bound forward if the saved environment disappears during binding', async () => { - const state = setup() - const original = environment() - let current: KnownRuntimeEnvironment | null = original - state.addForward.mockImplementationOnce(async () => { - current = null - return { id: 'late-forward', localPort: 46_768, remotePort: 6_768 } - }) - - await state.manager.ensure(original, () => current) - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('late-forward') - }) - - it('removes a newly bound forward if the environment closes during binding', async () => { - const state = setup() - state.addForward.mockImplementationOnce(async () => { - await state.manager.close('environment-1') - return { id: 'late-forward', localPort: 46_768, remotePort: 6_768 } - }) - - await state.manager.ensure(environment()) - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('late-forward') - await state.manager.recoverAfterHostResume(resumeOptions()) - expect(state.probeTunnel).not.toHaveBeenCalled() - }) - - it('keeps a healthy managed tunnel intact after host resume', async () => { - const state = setup() - await state.manager.ensure(environment()) - - await state.manager.recoverAfterHostResume(resumeOptions()) - - expect(state.probeTunnel).toHaveBeenCalledOnce() - expect(state.probeTunnel).toHaveBeenCalledWith( - expect.objectContaining({ id: 'environment-1' }), - 5_000 - ) - expect(state.reconnect).not.toHaveBeenCalled() - expect(state.removeForwardAndWait).not.toHaveBeenCalled() - }) - - it('retries a failed wake probe before reconnecting', async () => { - const state = setup() - state.probeTunnel.mockResolvedValueOnce(false).mockResolvedValueOnce(true) - await state.manager.ensure(environment()) - - await state.manager.recoverAfterHostResume(resumeOptions()) - - expect(state.probeTunnel).toHaveBeenCalledTimes(2) - expect(state.reconnect).not.toHaveBeenCalled() - }) - - it('reconnects and rebuilds the exact persisted port after failed wake probes', async () => { - const state = setup() - state.probeTunnel.mockResolvedValue(false) - await state.manager.ensure(environment()) - - await state.manager.recoverAfterHostResume(resumeOptions()) - - expect(state.reconnect).toHaveBeenCalledOnce() - expect(state.reconnect).toHaveBeenCalledWith('ssh-1') - expect(state.removeForwardAndWait).toHaveBeenCalledWith('forward-1') - expect(state.addForward).toHaveBeenLastCalledWith( - 'ssh-1', - state.connection, - 46_768, - '127.0.0.1', - 6_768, - 'Managed Orca server: Managed server' - ) - - await state.manager.ensure(environment()) - expect(state.addForward).toHaveBeenCalledTimes(2) - }) - - it('coalesces concurrent host-resume recoveries', async () => { - const state = setup() - let finishProbe!: () => void - state.probeTunnel.mockImplementationOnce( - () => - new Promise((resolve) => { - finishProbe = () => resolve(true) - }) - ) - await state.manager.ensure(environment()) - - const first = state.manager.recoverAfterHostResume(resumeOptions()) - const second = state.manager.recoverAfterHostResume(resumeOptions()) - expect(second).toBe(first) - finishProbe() - await Promise.all([first, second]) - - expect(state.probeTunnel).toHaveBeenCalledOnce() - }) - - it('does not reconnect an environment closed during its wake probe', async () => { - const state = setup() - let finishProbe!: () => void - state.probeTunnel.mockImplementationOnce( - () => - new Promise((resolve) => { - finishProbe = () => resolve(false) - }) - ) - await state.manager.ensure(environment()) - - const recovery = state.manager.recoverAfterHostResume({ - ...resumeOptions(), - attempts: 1 - }) - await vi.waitFor(() => expect(state.probeTunnel).toHaveBeenCalledOnce()) - await state.manager.close('environment-1') - finishProbe() - await recovery - - expect(state.reconnect).not.toHaveBeenCalled() - expect(state.addForward).toHaveBeenCalledOnce() - }) - - it('does not rebuild when reconnect did not establish a newer transport', async () => { - const state = setup() - state.probeTunnel.mockResolvedValue(false) - state.reconnect.mockImplementationOnce(async () => undefined) - await state.manager.ensure(environment()) - - await state.manager.recoverAfterHostResume({ - ...resumeOptions(), - attempts: 1 - }) - - expect(state.reconnect).toHaveBeenCalledOnce() - expect(state.removeForwardAndWait).not.toHaveBeenCalled() - expect(state.addForward).toHaveBeenCalledOnce() - }) - - it('does not rebuild after re-pairing removes SSH access during reconnect', async () => { - const state = setup() - let current = environment() - state.probeTunnel.mockResolvedValue(false) - await state.manager.ensure(current) - state.reconnect.mockImplementationOnce(async () => { - state.setTransportGeneration(4) - current = { ...current, orcadDeployment: undefined, sshAccess: undefined } - }) - - await state.manager.recoverAfterHostResume({ - ...resumeOptions(), - resolveEnvironment: () => current - }) - - expect(state.addForward).toHaveBeenCalledOnce() - expect(state.removeForwardAndWait).not.toHaveBeenCalled() - }) - - it('removes the replacement forward when re-pairing happens during resume binding', async () => { - const state = setup() - let current = environment() - state.probeTunnel.mockResolvedValue(false) - await state.manager.ensure(current) - state.addForward.mockImplementationOnce(async () => { - current = { ...current, orcadDeployment: undefined, sshAccess: undefined } - return { id: 'late-forward', localPort: 46_768, remotePort: 6_768 } - }) - - await state.manager.recoverAfterHostResume({ - ...resumeOptions(), - resolveEnvironment: () => current - }) - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('late-forward') - }) - } -) diff --git a/src/main/ssh/orcad-managed-tunnel.ts b/src/main/ssh/orcad-managed-tunnel.ts deleted file mode 100644 index a50578e170ba..000000000000 --- a/src/main/ssh/orcad-managed-tunnel.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { - getRuntimeSshAccess, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import type { SshTarget } from '../../shared/ssh-types' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { SshConnection } from './ssh-connection' -import type { SshConnectionManager } from './ssh-connection-manager' -import { SshPortForwardManager } from './ssh-port-forward' -import { - OrcadManagedTunnelResumeRecovery, - type ActiveOrcadTunnel, - type OrcadManagedTunnelProbe, - type OrcadManagedTunnelResumeOptions -} from './orcad-managed-tunnel-resume' -import { getSshConnectionManager, getSshTargetRegistryStore } from './ssh-target-registry' - -type OrcadManagedTunnelDependencies = { - getConnectionManager: () => SshConnectionManager | null - getTargetStore: () => ReturnType - forwardManager?: SshPortForwardManager - probeTunnel?: OrcadManagedTunnelProbe -} - -export class OrcadManagedTunnelManager { - private readonly active = new Map() - private readonly inFlight = new Map>() - private readonly ownershipGenerations = new Map() - private readonly forwards: SshPortForwardManager - private readonly resumeRecovery: OrcadManagedTunnelResumeRecovery - private managerGeneration = 0 - - constructor(private readonly dependencies: OrcadManagedTunnelDependencies) { - this.forwards = dependencies.forwardManager ?? new SshPortForwardManager() - this.resumeRecovery = new OrcadManagedTunnelResumeRecovery({ - active: this.active, - forwards: this.forwards, - getConnectionManager: dependencies.getConnectionManager, - getManagerGeneration: () => this.managerGeneration, - getTargetStore: dependencies.getTargetStore, - inFlight: this.inFlight, - ownershipGenerations: this.ownershipGenerations, - probeTunnel: dependencies.probeTunnel - }) - this.forwards.setCallbacks({ - onForwardClosed: (entry) => { - for (const [environmentId, active] of this.active) { - if (active.forwardId === entry.id) { - this.active.delete(environmentId) - } - } - } - }) - } - - ensure( - environment: KnownRuntimeEnvironment, - resolveCurrent: () => KnownRuntimeEnvironment | null = () => environment - ): Promise { - if (!getRuntimeSshAccess(environment)) { - return Promise.resolve() - } - const pending = this.inFlight.get(environment.id) - if (pending) { - return pending - } - const operation = this.ensureManagedTunnel(environment, resolveCurrent).finally(() => { - if (this.inFlight.get(environment.id) === operation) { - this.inFlight.delete(environment.id) - } - }) - this.inFlight.set(environment.id, operation) - return operation - } - - async start( - environmentId: string, - target: SshTarget, - connection: SshConnection, - remotePort: number - ): Promise { - if (!target.generation) { - throw new Error('Managed Orca SSH target has no registration generation.') - } - const managerGeneration = this.managerGeneration - const ownershipGeneration = (this.ownershipGenerations.get(environmentId) ?? 0) + 1 - const transportGeneration = connection.getTransportGeneration() - const stillCurrent = (): boolean => - this.managerGeneration === managerGeneration && - this.ownershipGenerations.get(environmentId) === ownershipGeneration && - connection.getTransportGeneration() === transportGeneration - await this.close(environmentId) - if (!stillCurrent()) { - throw new Error('Orca SSH tunnel setup was superseded.') - } - const forward = await this.forwards.addForward( - target.id, - connection, - 0, - '127.0.0.1', - remotePort, - `Managed Orca server` - ) - if (!stillCurrent()) { - await this.forwards.removeForwardAndWait(forward.id) - throw new Error('Orca SSH tunnel setup was superseded.') - } - this.active.set(environmentId, { - connection, - forwardId: forward.id, - localPort: forward.localPort, - remotePort: forward.remotePort, - sshTargetGeneration: target.generation, - targetId: target.id, - transportGeneration - }) - return forward.localPort - } - - async close(environmentId: string): Promise { - this.ownershipGenerations.set( - environmentId, - (this.ownershipGenerations.get(environmentId) ?? 0) + 1 - ) - const active = this.active.get(environmentId) - if (!active) { - return - } - this.active.delete(environmentId) - await this.forwards.removeForwardAndWait(active.forwardId) - } - - dispose(): void { - this.managerGeneration += 1 - this.active.clear() - this.inFlight.clear() - this.ownershipGenerations.clear() - this.resumeRecovery.dispose() - this.forwards.dispose() - } - - recoverAfterHostResume(options: OrcadManagedTunnelResumeOptions): Promise { - return this.resumeRecovery.recover(options) - } - - private async ensureManagedTunnel( - environment: KnownRuntimeEnvironment, - resolveCurrent: () => KnownRuntimeEnvironment | null - ): Promise { - const deployment = getRuntimeSshAccess(environment) - if (!deployment || environment.connectionDependency !== 'ssh-tunnel') { - throw new Error('Managed orcad environment is missing its SSH tunnel dependency.') - } - const targetStore = this.dependencies.getTargetStore() - const connectionManager = this.dependencies.getConnectionManager() - if (!targetStore || !connectionManager) { - throw new Error('SSH is unavailable on this client; the managed Orca server is unverifiable.') - } - const target = targetStore.getTarget(deployment.sshTargetId) - if (!target || target.generation !== deployment.sshTargetGeneration) { - throw new Error( - 'The SSH registration for this managed Orca server was removed or re-created.' - ) - } - if (getManagedOrcadOwnerEnvironmentId(target.owner) !== environment.id) { - throw new Error('The SSH target is no longer owned by this managed Orca server.') - } - - const managerGeneration = this.managerGeneration - const ownershipGeneration = this.ownershipGenerations.get(environment.id) ?? 0 - const stillOwned = (): boolean => { - const currentTarget = targetStore.getTarget(target.id) - const currentEnvironment = resolveCurrent() - const currentAccess = currentEnvironment ? getRuntimeSshAccess(currentEnvironment) : undefined - return ( - this.managerGeneration === managerGeneration && - (this.ownershipGenerations.get(environment.id) ?? 0) === ownershipGeneration && - currentTarget?.generation === target.generation && - getManagedOrcadOwnerEnvironmentId(currentTarget?.owner) === environment.id && - currentEnvironment?.id === environment.id && - currentEnvironment.runtimeId === environment.runtimeId && - (currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === - (environment.pairingRevision ?? environment.createdAt) && - currentEnvironment.connectionDependency === 'ssh-tunnel' && - currentAccess?.sshTargetId === deployment.sshTargetId && - currentAccess.sshTargetGeneration === deployment.sshTargetGeneration && - currentAccess.localPort === deployment.localPort && - currentAccess.remotePort === deployment.remotePort - ) - } - const connection = await connectionManager.connect(target) - if (!stillOwned()) { - return - } - const transportGeneration = connection.getTransportGeneration() - const active = this.active.get(environment.id) - if ( - active?.connection === connection && - active.transportGeneration === transportGeneration && - active.targetId === target.id && - active.sshTargetGeneration === target.generation && - active.localPort === deployment.localPort && - active.remotePort === deployment.remotePort - ) { - return - } - if (active) { - await this.forwards.removeForwardAndWait(active.forwardId) - if (this.active.get(environment.id) === active) { - this.active.delete(environment.id) - } - } - if (!stillOwned()) { - return - } - const forward = await this.forwards.addForward( - target.id, - connection, - deployment.localPort, - '127.0.0.1', - deployment.remotePort, - `Managed Orca server: ${environment.name}` - ) - if (forward.localPort !== deployment.localPort) { - await this.forwards.removeForwardAndWait(forward.id) - throw new Error('Managed Orca tunnel bound an unexpected local port.') - } - if (!stillOwned() || connection.getTransportGeneration() !== transportGeneration) { - await this.forwards.removeForwardAndWait(forward.id) - return - } - this.active.set(environment.id, { - connection, - forwardId: forward.id, - localPort: forward.localPort, - remotePort: forward.remotePort, - sshTargetGeneration: target.generation, - targetId: target.id, - transportGeneration - }) - } -} - -const managedTunnels = new OrcadManagedTunnelManager({ - getConnectionManager: getSshConnectionManager, - getTargetStore: getSshTargetRegistryStore -}) - -export async function ensureOrcadManagedTunnel( - userDataPath: string, - selector: string -): Promise { - const environment = resolveEnvironment(userDataPath, selector) - await managedTunnels.ensure(environment, () => { - try { - return resolveEnvironment(userDataPath, environment.id) - } catch { - return null - } - }) -} - -export function disposeOrcadManagedTunnels(): void { - managedTunnels.dispose() -} - -export function recoverOrcadManagedTunnelsAfterHostResume( - userDataPath: string, - options: { attempts: number; timeoutMs: number } -): Promise { - return managedTunnels.recoverAfterHostResume({ - ...options, - resolveEnvironment: (environmentId) => { - try { - return resolveEnvironment(userDataPath, environmentId) - } catch { - return null - } - } - }) -} - -export function startOrcadManagedTunnel( - environmentId: string, - target: SshTarget, - connection: SshConnection, - remotePort: number -): Promise { - return managedTunnels.start(environmentId, target, connection, remotePort) -} - -export function closeOrcadManagedTunnel(environmentId: string): Promise { - return managedTunnels.close(environmentId) -} diff --git a/src/main/ssh/orcad-runtime-deployment.test.ts b/src/main/ssh/orcad-runtime-deployment.test.ts deleted file mode 100644 index 2faf1d7cb5df..000000000000 --- a/src/main/ssh/orcad-runtime-deployment.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import { listEnvironments } from '../../shared/runtime-environment-store' -import type { SshTarget } from '../../shared/ssh-types' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' - -const mocks = vi.hoisted(() => { - const state: { store: unknown } = { store: null } - return { - state, - events: new Array(), - connect: vi.fn(), - hasDirectAuthority: vi.fn(), - resolveContext: vi.fn(), - recover: vi.fn(), - readRecord: vi.fn(), - deploy: vi.fn(), - probe: vi.fn(), - startTunnel: vi.fn(), - ensureTunnel: vi.fn(), - closeTunnel: vi.fn(), - readTransaction: vi.fn() - } -}) - -vi.mock('./ssh-target-registry', () => ({ - getSshConnectionManager: () => ({ connect: mocks.connect }), - getSshTargetRegistryStore: () => mocks.state.store, - hasRegisteredDirectSshAuthority: mocks.hasDirectAuthority -})) -vi.mock('./orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext })) -vi.mock('./orcad-activation-recovery', () => ({ recoverInterruptedOrcadActivation: mocks.recover })) -vi.mock('./orcad-activation-record-store', () => ({ readOrcadActivationRecord: mocks.readRecord })) -vi.mock('./orcad-activation-transaction-store', () => ({ - readOrcadActivationTransaction: mocks.readTransaction -})) -vi.mock('./orcad-remote-deploy', () => ({ deployOrcad: mocks.deploy })) -vi.mock('./orcad-artifact-materializer', () => ({ - materializeOrcadArtifact: async () => '/local/orcad' -})) -vi.mock('./orcad-local-build-hash', () => ({ computeLocalOrcadBuildHash: () => 'build-hash' })) -vi.mock('./orcad-active-readiness', () => ({ probeActiveOrcadReadiness: mocks.probe })) -vi.mock('./orcad-managed-tunnel', () => ({ - startOrcadManagedTunnel: mocks.startTunnel, - ensureOrcadManagedTunnel: mocks.ensureTunnel, - closeOrcadManagedTunnel: mocks.closeTunnel -})) - -const { createManagedOrcadEnvironment, getManagedOrcadRuntimeStatus } = - await import('./orcad-runtime-lifecycle') - -const VERSION = '0.1.0+abc123' -const emptyRecord = { active: null, previous: null, activatedAt: null, snapshot: null } - -function readiness() { - const endpoint = 'ws://127.0.0.1:6768' - return { - runtimeId: 'runtime-1', - boundEndpoint: endpoint, - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { - available: true, - url: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint, - deviceToken: 'device-token', - publicKeyB64: 'public-key' - }), - endpoint, - deviceId: 'device-1', - webClientUrl: null, - scope: 'runtime', - qr: null - } - } -} - -let userDataPath: string -let target: SshTarget -let flushes: number - -function setupStore(overrides: { repos?: { connectionId: string }[] } = {}) { - const store = { - allocateSshTargetGeneration: () => 9, - flushPendingOrThrowAsync: async () => { - flushes += 1 - mocks.events.push('flush') - }, - getFolderWorkspaces: () => [], - getRepos: () => overrides.repos ?? [], - ...emptyDependentStateStore(), - getSshTarget: (id: string) => (id === target.id ? target : undefined), - getSshTargets: () => [target], - updateSshTarget: (_id: string, updates: Partial) => { - target = { ...target, ...updates } - return target - } - } - mocks.state.store = { - getTarget: (id: string) => (id === target.id ? target : undefined), - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the claims read only the store methods stubbed above. - getOrcadRuntimeClaims: () => new SshTargetOrcadClaims(store as never) - } -} - -beforeEach(() => { - vi.resetAllMocks() - mocks.events.length = 0 - flushes = 0 - userDataPath = mkdtempSync(join(tmpdir(), 'orcad-deployment-test-')) - target = { id: 'ssh-1', label: 'Builder', host: 'builder', port: 22, username: 'dev' } - setupStore() - mocks.hasDirectAuthority.mockReturnValue(false) - mocks.connect.mockImplementation(async () => { - mocks.events.push('connect') - return {} - }) - mocks.resolveContext.mockImplementation(async (claimed: SshTarget) => ({ - activationRecord: emptyRecord, - serverTarget: 'linux-x64-glibc', - connection: {}, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/dev', - target: claimed, - userDataDir: '/home/dev/.orca' - })) - mocks.recover.mockResolvedValue({ outcome: 'none' }) - mocks.deploy.mockResolvedValue({ outcome: 'installed-and-activated', fullVersion: VERSION }) - mocks.probe.mockResolvedValue(readiness()) - mocks.startTunnel.mockResolvedValue(46_768) - mocks.closeTunnel.mockResolvedValue(undefined) - mocks.ensureTunnel.mockResolvedValue(undefined) -}) - -afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) - -const deploy = () => - createManagedOrcadEnvironment(userDataPath, { name: 'Managed', sshTargetId: 'ssh-1' }) - -describe('createManagedOrcadEnvironment', () => { - it('claims the target durably before contacting it, then registers a tunneled server', async () => { - const result = await deploy() - - expect(mocks.events.slice(0, 2)).toEqual(['flush', 'connect']) - expect(result).toMatchObject({ outcome: 'created', activeVersion: VERSION }) - const [environment] = listEnvironments(userDataPath) - expect(environment?.orcadDeployment).toEqual({ - sshTargetId: 'ssh-1', - sshTargetGeneration: 9, - localPort: 46_768, - remotePort: 6_768 - }) - expect(environment?.connectionDependency).toBe('ssh-tunnel') - expect(getManagedOrcadOwnerEnvironmentId(target.owner)).toBe(environment?.id) - expect(target.orcadProvisioning).toEqual({ requestId: environment?.id, name: 'Managed' }) - expect(JSON.stringify(result)).not.toContain('device-token') - expect(mocks.probe).toHaveBeenCalledWith( - expect.objectContaining({ remoteInstallDir: expect.any(String) }), - { - buildHash: 'build-hash', - fullVersion: VERSION - } - ) - expect(mocks.closeTunnel).not.toHaveBeenCalled() - }) - - it('deploys an empty host with a zero census and an unknown one over an active slot', async () => { - await deploy() - expect(mocks.deploy.mock.calls[0]?.[0]).toMatchObject({ - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }, - nodePath: '' - }) - }) - - it('refuses a host with direct SSH projects before claiming or connecting', async () => { - setupStore({ repos: [{ connectionId: 'ssh-1' }] }) - await expect(deploy()).rejects.toThrow('repositories or folder workspaces') - expect(target.owner).toBeUndefined() - expect(mocks.connect).not.toHaveBeenCalled() - }) - - it('refuses a host that saved state still references, naming it, before claiming', async () => { - const leases = [{ ptyId: 'pty-1', state: 'expired' }] - const store = { - ...emptyDependentStateStore({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only ptyId and state. - getSshRemotePtyLeases: () => leases as never - }), - allocateSshTargetGeneration: () => 9, - flushPendingOrThrowAsync: async () => {}, - getFolderWorkspaces: () => [], - getRepos: () => [], - getSshTarget: () => target, - getSshTargets: () => [target], - updateSshTarget: (_id: string, updates: Partial) => - (target = { ...target, ...updates }) - } - mocks.state.store = { - getTarget: () => target, - getOrcadRuntimeClaims: () => new SshTargetOrcadClaims(store) - } - await expect(deploy()).rejects.toThrow('terminal-lease ×1 (pty-1 (expired))') - expect(target.owner).toBeUndefined() - expect(mocks.connect).not.toHaveBeenCalled() - }) - - it('refuses a connected direct SSH session and a taken server name', async () => { - mocks.hasDirectAuthority.mockReturnValueOnce(true) - await expect(deploy()).rejects.toThrow('Disconnect this SSH host') - await deploy() - target = { ...target, id: 'ssh-2', owner: undefined } - await expect( - createManagedOrcadEnvironment(userDataPath, { name: 'Managed', sshTargetId: 'ssh-2' }) - ).rejects.toThrow('already exists') - }) - - it('does not contact the host when the claim cannot be made durable', async () => { - const claims = new SshTargetOrcadClaims({ - allocateSshTargetGeneration: () => 9, - flushPendingOrThrowAsync: async () => { - throw new Error('disk full') - }, - getFolderWorkspaces: () => [], - getRepos: () => [], - ...emptyDependentStateStore(), - getSshTarget: () => target, - getSshTargets: () => [target], - updateSshTarget: (_id, updates) => (target = { ...target, ...updates }) - }) - mocks.state.store = { getTarget: () => target, getOrcadRuntimeClaims: () => claims } - await expect(deploy()).rejects.toThrow('disk full') - expect(mocks.connect).not.toHaveBeenCalled() - }) - - it('returns a deferral, keeps the claim and closes nothing it did not open', async () => { - mocks.deploy.mockResolvedValueOnce({ - outcome: 'installed-not-activated', - fullVersion: VERSION, - code: 'orcad_update_terminal_census_unavailable', - reason: 'unknown census' - }) - await expect(deploy()).resolves.toMatchObject({ outcome: 'deferred', forceable: false }) - expect(listEnvironments(userDataPath)).toEqual([]) - expect(getManagedOrcadOwnerEnvironmentId(target.owner)).not.toBeNull() - expect(mocks.startTunnel).not.toHaveBeenCalled() - }) - - it('resumes an interrupted deploy under the environment id its claim recorded', async () => { - mocks.probe.mockRejectedValueOnce(new Error('readiness unverifiable')) - await expect(deploy()).rejects.toThrow('readiness unverifiable') - const claimedId = getManagedOrcadOwnerEnvironmentId(target.owner) - expect(claimedId).not.toBeNull() - mocks.deploy.mockResolvedValueOnce({ outcome: 'already-active', fullVersion: VERSION }) - const result = await deploy() - expect(result).toMatchObject({ outcome: 'already-current' }) - expect(listEnvironments(userDataPath).map((entry) => entry.id)).toEqual([claimedId]) - }) - - it('closes the tunnel it opened when registration fails', async () => { - mocks.probe.mockResolvedValueOnce({ - ...readiness(), - pairing: { available: false, guidance: 'off' } - }) - mocks.startTunnel.mockResolvedValueOnce(46_768) - await expect(deploy()).rejects.toThrow('did not publish a pairing offer') - expect(mocks.closeTunnel).toHaveBeenCalledOnce() - expect(JSON.stringify(mocks.closeTunnel.mock.calls)).not.toContain('device-token') - }) - - it('finishes an already registered server by ensuring its tunnel, without redeploying', async () => { - await deploy() - mocks.resolveContext.mockImplementation(async (claimed: SshTarget) => ({ - activationRecord: { ...emptyRecord, active: VERSION }, - serverTarget: 'linux-x64-glibc', - connection: {}, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/dev', - target: claimed, - userDataDir: '/home/dev/.orca' - })) - await expect(deploy()).resolves.toMatchObject({ outcome: 'already-current' }) - expect(mocks.deploy).toHaveBeenCalledOnce() - expect(mocks.ensureTunnel).toHaveBeenCalledOnce() - }) - - it('stops at an interrupted activation the host cannot reconcile yet', async () => { - mocks.recover.mockResolvedValueOnce({ - outcome: 'pending', - code: 'fresh', - reason: 'still fresh' - }) - await expect(deploy()).rejects.toThrow('still fresh') - expect(mocks.deploy).not.toHaveBeenCalled() - }) -}) - -describe('getManagedOrcadRuntimeStatus', () => { - it('reports the activation record and an interrupted transaction without repairing it', async () => { - await deploy() - const [environment] = listEnvironments(userDataPath) - mocks.resolveContext.mockImplementation(async (claimed: SshTarget) => ({ - activationRecord: { ...emptyRecord, active: VERSION, activatedAt: 'now' }, - connection: {}, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/dev', - target: claimed - })) - mocks.readTransaction.mockResolvedValueOnce({ - operation: 'activate', - phase: 'candidate-ready', - candidateVersion: '0.2.0+def', - startedAt: 'then' - }) - await expect(getManagedOrcadRuntimeStatus(userDataPath, 'Managed')).resolves.toEqual({ - environmentId: environment?.id, - sshTargetId: 'ssh-1', - activeVersion: VERSION, - previousVersion: null, - activatedAt: 'now', - rollbackAvailable: false, - recovery: { - operation: 'activate', - phase: 'candidate-ready', - version: '0.2.0+def', - startedAt: 'then' - } - }) - expect(mocks.recover).toHaveBeenCalledTimes(1) - }) - - it('reports an interrupted decommission without finishing it', async () => { - await deploy() - mocks.readTransaction.mockResolvedValueOnce({ - operation: 'decommission', - phase: 'stop-dispatched', - activeVersion: VERSION, - startedAt: 'then' - }) - await expect(getManagedOrcadRuntimeStatus(userDataPath, 'Managed')).resolves.toMatchObject({ - recovery: { operation: 'decommission', phase: 'stop-dispatched', version: VERSION } - }) - }) - - it('refuses a server whose SSH registration was re-created', async () => { - await deploy() - target = { ...target, generation: 10 } - await expect(getManagedOrcadRuntimeStatus(userDataPath, 'Managed')).rejects.toThrow( - 'no longer valid' - ) - }) -}) diff --git a/src/main/ssh/orcad-runtime-deployment.ts b/src/main/ssh/orcad-runtime-deployment.ts deleted file mode 100644 index 575ad10da7e4..000000000000 --- a/src/main/ssh/orcad-runtime-deployment.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Deploys orcad onto an empty SSH host and pairs this client with it through a loopback tunnel. - * The target claim is the resume point: an interrupted deploy leaves the target owned by the - * environment id it was creating, and the next deploy of that target finishes the same one. - */ -import { randomUUID } from 'node:crypto' -import { assertRuntimeEnvironmentNotReconciling } from '../../shared/runtime-environment-reconciliation-record' -import { listEnvironments } from '../../shared/runtime-environment-store' -import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' -import { redactRuntimeEnvironment } from '../../shared/runtime-environments' -import { - ORCAD_MANAGED_REMOTE_PORT, - type OrcadManagedDeployResult -} from '../../shared/orcad-managed-runtime' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import { materializeOrcadArtifact } from './orcad-artifact-materializer' -import { - closeOrcadManagedTunnel, - ensureOrcadManagedTunnel, - startOrcadManagedTunnel -} from './orcad-managed-tunnel' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { readOrcadActivationRecord } from './orcad-activation-record-store' -import { resolveOrcadRemoteContext } from './orcad-remote-context' -import { deployOrcad } from './orcad-remote-deploy' -import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' -import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' -import { - isForceableOrcadDeferral, - MANAGED_ORCAD_LEGACY_NODE_PATH, - ORCAD_BIND_HOST, - probeManagedOrcadReadiness, - requireManagedOrcadInfrastructure -} from './orcad-managed-runtime-context' - -export async function createManagedOrcadEnvironment( - userDataPath: string, - args: { name: string; sshTargetId: string; force?: boolean; signal?: AbortSignal } -): Promise { - return runTargetLifecycle(args.sshTargetId, async () => { - const { connectionManager, targetStore, claims } = requireManagedOrcadInfrastructure() - const environmentId = - getManagedOrcadOwnerEnvironmentId(targetStore.getTarget(args.sshTargetId)?.owner) ?? - randomUUID() - const environments = listEnvironments(userDataPath) - const registered = environments.find((entry) => entry.id === environmentId) - if (registered) { - assertRuntimeEnvironmentNotReconciling(registered) - if (registered.orcadDeployment?.sshTargetId !== args.sshTargetId) { - throw new Error('The SSH target is owned by a server that is not deployed on it.') - } - } - if (environments.some((entry) => entry.id !== environmentId && entry.name === args.name)) { - throw new Error(`A server named "${args.name}" already exists.`) - } - if (hasRegisteredDirectSshAuthority(args.sshTargetId)) { - throw new Error('Disconnect this SSH host before converting it to a managed Orca server.') - } - const claimed = claims.claim(args.sshTargetId, environmentId, args.name) - await claims.flush(args.signal) - const targetGeneration = claimed.generation - if (targetGeneration === undefined) { - throw new Error('The managed Orca SSH registration has no durable generation.') - } - if (registered && registered.orcadDeployment?.sshTargetGeneration !== targetGeneration) { - throw new Error('The saved managed Orca server has a stale SSH target generation.') - } - let environmentRegistered = false - try { - const connection = await connectionManager.connect(claimed) - let context = await resolveOrcadRemoteContext(claimed, connection, args.signal) - const slot = { - conn: context.connection, - host: context.host, - remoteHome: context.remoteHome, - nodePath: MANAGED_ORCAD_LEGACY_NODE_PATH, - userDataDir: context.userDataDir, - bindHost: ORCAD_BIND_HOST, - port: ORCAD_MANAGED_REMOTE_PORT, - signal: args.signal - } - const recovery = await recoverInterruptedOrcadActivation(slot) - if (recovery.outcome === 'pending' || recovery.outcome === 'refused') { - throw new Error(recovery.reason) - } - if (recovery.outcome === 'recovered') { - context = { ...context, activationRecord: await readOrcadActivationRecord(slot) } - } - if (registered) { - environmentRegistered = true - await ensureOrcadManagedTunnel(userDataPath, registered.id) - const activeVersion = context.activationRecord.active - if (!activeVersion) { - throw new Error('The managed Orca server has no active runtime version.') - } - return { - outcome: 'already-current', - environment: redactRuntimeEnvironment(registered), - activeVersion - } - } - const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { - signal: args.signal - }) - const deployResult = await deployOrcad({ - ...slot, - conn: connection, - localOrcadDir, - target: context.serverTarget, - // Why unknown when a version is active: only the daemon can count its sessions, and - // a deploy that guessed zero would restart over live terminals. - census: context.activationRecord.active - ? { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: null } - : { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }, - force: args.force - }) - if (deployResult.outcome === 'installed-not-activated') { - return { - outcome: 'deferred', - candidateVersion: deployResult.fullVersion, - code: deployResult.code, - reason: deployResult.reason, - forceable: isForceableOrcadDeferral(deployResult.code) - } - } - const readiness = await probeManagedOrcadReadiness( - context, - localOrcadDir, - deployResult.fullVersion, - args.signal - ) - const localPort = await startOrcadManagedTunnel( - environmentId, - claimed, - connection, - ORCAD_MANAGED_REMOTE_PORT - ) - const environment = addManagedOrcadEnvironment(userDataPath, { - id: environmentId, - name: args.name, - pairingCode: tunneledOrcadPairingCode(readiness, localPort), - orcadDeployment: { - sshTargetId: claimed.id, - sshTargetGeneration: targetGeneration, - localPort, - remotePort: ORCAD_MANAGED_REMOTE_PORT - } - }) - environmentRegistered = true - return { - outcome: deployResult.outcome === 'already-active' ? 'already-current' : 'created', - environment: redactRuntimeEnvironment(environment), - activeVersion: deployResult.fullVersion - } - } finally { - if (!environmentRegistered) { - await closeOrcadManagedTunnel(environmentId).catch(() => undefined) - } - } - }) -} diff --git a/src/main/ssh/orcad-runtime-lifecycle.ts b/src/main/ssh/orcad-runtime-lifecycle.ts deleted file mode 100644 index 247f6ca1732b..000000000000 --- a/src/main/ssh/orcad-runtime-lifecycle.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createManagedOrcadEnvironment } from './orcad-runtime-deployment' -export { getManagedOrcadRuntimeStatus } from './orcad-runtime-status' diff --git a/src/main/ssh/orcad-runtime-status.ts b/src/main/ssh/orcad-runtime-status.ts deleted file mode 100644 index 639bd7a41994..000000000000 --- a/src/main/ssh/orcad-runtime-status.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { OrcadManagedRuntimeStatus } from '../../shared/orcad-managed-runtime' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import type { OrcadActivationTransaction } from './orcad-activation-transaction' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { - requireManagedOrcadEnvironment, - resolveLinkedOrcadContext -} from './orcad-managed-runtime-context' - -/** Read-only: what the host's activation record and journal say, without repairing either. */ -export async function getManagedOrcadRuntimeStatus( - userDataPath: string, - selector: string, - signal?: AbortSignal -): Promise { - const { environment, deployment } = requireManagedOrcadEnvironment(userDataPath, selector) - return runTargetLifecycle(deployment.sshTargetId, async () => { - const context = await resolveLinkedOrcadContext(environment, deployment, signal) - const record = context.activationRecord - const transaction = await readOrcadActivationTransaction({ - conn: context.connection, - host: context.host, - remoteHome: context.remoteHome, - signal - }) - return { - environmentId: environment.id, - sshTargetId: context.target.id, - activeVersion: record.active, - previousVersion: record.previous, - activatedAt: record.activatedAt, - rollbackAvailable: Boolean(record.previous && record.snapshot), - recovery: transaction ? managedRecoveryStatus(transaction) : null - } - }) -} - -function managedRecoveryStatus( - transaction: OrcadActivationTransaction -): NonNullable { - switch (transaction.operation) { - case 'activate': - return { - operation: transaction.operation, - phase: transaction.phase, - version: transaction.candidateVersion, - startedAt: transaction.startedAt - } - case 'rollback': - return { - operation: transaction.operation, - phase: transaction.phase, - version: transaction.targetVersion, - startedAt: transaction.startedAt - } - case 'decommission': - return { - operation: transaction.operation, - phase: transaction.phase, - version: transaction.activeVersion, - startedAt: transaction.startedAt - } - } -} diff --git a/src/main/ssh/orcad-ssh-provisioning.test.ts b/src/main/ssh/orcad-ssh-provisioning.test.ts deleted file mode 100644 index 4640f0b93d5b..000000000000 --- a/src/main/ssh/orcad-ssh-provisioning.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshTarget } from '../../shared/ssh-types' -import { normalizeSshTarget } from '../persistence/leasing-ssh-ptys/ssh-normalization' - -const mocks = vi.hoisted(() => { - const targets: SshTarget[] = [] - const deployedTargets: string[] = [] - const targetStore: Record = {} - return { - targets, - deployedTargets, - update: vi.fn(), - deploy: vi.fn(), - flush: vi.fn(), - add: vi.fn(), - rotate: vi.fn(), - targetStore - } -}) - -vi.mock('./orcad-managed-runtime-context', () => ({ - requireManagedOrcadTargetStore: () => mocks.targetStore -})) -vi.mock('./orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) -vi.mock('./ssh-provider-authority', () => ({ rotateSshProviderAuthority: mocks.rotate })) -vi.mock('../../shared/runtime-environment-store', () => ({ - listEnvironments: () => - mocks.deployedTargets.map((sshTargetId) => ({ orcadDeployment: { sshTargetId } })) -})) - -import { - createOrcadSshHost, - listPendingOrcadSshProvisioning, - resumeOrcadSshHost -} from './orcad-ssh-provisioning' - -const request = { - requestId: 'request-1', - name: 'Build host', - target: { label: 'Build host', host: 'builder', port: 22, username: 'dev' } -} -const deployed = { - outcome: 'created', - environment: { id: 'environment-1' }, - activeVersion: 'node-1' -} - -beforeEach(() => { - vi.clearAllMocks() - mocks.targets.length = 0 - mocks.deployedTargets.length = 0 - mocks.flush.mockReset().mockResolvedValue(undefined) - mocks.deploy.mockReset().mockResolvedValue(deployed) - mocks.add.mockImplementation((input) => { - const target = normalizeSshTarget({ - ...input, - id: `ssh-${mocks.targets.length + 1}`, - generation: 1 - }) - mocks.targets.push(target) - return target - }) - mocks.update.mockImplementation((id: string, updates: Partial) => { - const index = mocks.targets.findIndex((target) => target.id === id) - mocks.targets[index] = { ...mocks.targets[index]!, ...updates } - return mocks.targets[index] - }) - mocks.targetStore = { - addTarget: mocks.add, - updateTarget: mocks.update, - lastRepoReadoptions: [], - getOrcadRuntimeClaims: () => ({ listTargets: () => mocks.targets, flush: mocks.flush }) - } -}) - -describe('managed SSH host provisioning', () => { - it('persists intent before running the existing migration/deployment preflight', async () => { - mocks.deploy.mockImplementation(async () => { - expect(mocks.flush).toHaveBeenCalledOnce() - expect(mocks.targets[0]?.orcadProvisioning).toEqual({ - requestId: 'request-1', - name: 'Build host' - }) - return deployed - }) - await expect(createOrcadSshHost('/profile', request)).resolves.toMatchObject({ - requestId: 'request-1', - name: 'Build host', - sshTargetId: 'ssh-1', - result: deployed - }) - expect(mocks.deploy).toHaveBeenCalledWith('/profile', { - name: 'Build host', - sshTargetId: 'ssh-1' - }) - }) - - it('does not contact a host if durable intent publication fails', async () => { - mocks.flush.mockRejectedValueOnce(new Error('disk full')) - await expect(createOrcadSshHost('/profile', request)).rejects.toThrow('disk full') - expect(mocks.deploy).not.toHaveBeenCalled() - await resumeOrcadSshHost('/profile', 'request-1') - expect(mocks.add).toHaveBeenCalledOnce() - }) - - it('retains the same request across preflight refusal and a reconstructed target store', async () => { - mocks.deploy.mockRejectedValueOnce(new Error('live-or-unverifiable terminal leases')) - await expect(createOrcadSshHost('/profile', request)).resolves.toMatchObject({ - result: { outcome: 'pending', reason: 'live-or-unverifiable terminal leases' } - }) - mocks.targets.splice(0, 1, JSON.parse(JSON.stringify(mocks.targets[0]))) - expect(listPendingOrcadSshProvisioning('/profile')).toEqual([ - { requestId: 'request-1', name: 'Build host', sshTargetId: 'ssh-1' } - ]) - await expect(resumeOrcadSshHost('/profile', 'request-1')).resolves.toMatchObject({ - result: deployed - }) - expect(mocks.add).toHaveBeenCalledOnce() - expect(mocks.deploy).toHaveBeenCalledTimes(2) - }) - - it('restores a host with re-adopted projects as direct SSH instead of hiding them', async () => { - const readoptions = [{ oldTargetId: 'old', newTargetId: 'ssh-1', repoIds: ['repo-1'] }] - mocks.targetStore.lastRepoReadoptions = readoptions - const result = await createOrcadSshHost('/profile', request) - expect(result.repoReadoptions).toEqual(readoptions) - expect(result.result).toMatchObject({ outcome: 'pending' }) - expect(mocks.rotate).toHaveBeenCalledWith('old') - expect(mocks.rotate).toHaveBeenCalledWith('ssh-1') - expect(mocks.targets[0]?.orcadProvisioning).toBeUndefined() - expect(listPendingOrcadSshProvisioning('/profile')).toEqual([]) - expect(mocks.deploy).not.toHaveBeenCalled() - }) - - it('returns activation deferral without force, deletion, or another target', async () => { - const deferred = { - outcome: 'deferred', - reason: 'unverifiable', - code: 'blocked', - candidateVersion: 'node-1' - } - mocks.deploy.mockResolvedValueOnce(deferred) - await expect(createOrcadSshHost('/profile', request)).resolves.toMatchObject({ - result: deferred - }) - expect(listPendingOrcadSshProvisioning('/profile')).toHaveLength(1) - expect(mocks.targets).toHaveLength(1) - }) - - it('serializes repeated requests and preserves normalization on exact retries', async () => { - const normalizedRequest = { - ...request, - target: { ...request.target, relayGracePeriodSeconds: 10800 } - } - await Promise.all([ - createOrcadSshHost('/profile', normalizedRequest), - createOrcadSshHost('/profile', normalizedRequest) - ]) - expect(mocks.add).toHaveBeenCalledOnce() - expect(mocks.targets).toHaveLength(1) - }) - - it('rejects request identity reuse for different host data or name', async () => { - await createOrcadSshHost('/profile', request) - await expect(createOrcadSshHost('/profile', { ...request, name: 'Other' })).rejects.toThrow( - 'already belongs' - ) - await expect( - createOrcadSshHost('/profile', { - ...request, - target: { ...request.target, identityFile: 'other' } - }) - ).rejects.toThrow('already belongs') - expect(mocks.deploy).toHaveBeenCalledOnce() - }) - - it('does not create a duplicate raw host when another request registered the endpoint', async () => { - await createOrcadSshHost('/profile', request) - await expect( - createOrcadSshHost('/profile', { ...request, requestId: 'request-2' }) - ).rejects.toThrow('already registered') - expect(mocks.add).toHaveBeenCalledOnce() - }) - - it('does not duplicate an occupied config alias or endpoint under a different label', async () => { - await createOrcadSshHost('/profile', request) - await expect( - createOrcadSshHost('/profile', { - ...request, - requestId: 'request-2', - target: { ...request.target, configHost: 'BUILDER', host: 'resolved-address' } - }) - ).rejects.toThrow('already registered') - await expect( - createOrcadSshHost('/profile', { - ...request, - requestId: 'request-3', - target: { ...request.target, label: 'Different', configHost: 'other-alias' } - }) - ).rejects.toThrow('already registered') - }) - - it.each([ - { configHost: 3 }, - { identityFile: false }, - { gssapiAuthentication: 'yes' }, - { systemSshConnectionReuse: 1 }, - { portForwards: [{ localPort: -1 }] } - ])('rejects malformed optional connection fields before durable registration: %j', (invalid) => { - expect(() => - createOrcadSshHost('/profile', { - ...request, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately malformed renderer input. - target: { ...request.target, ...invalid } as never - }) - ).toThrow() - expect(mocks.add).not.toHaveBeenCalled() - }) - - it('omits completed requests from pending discovery but retains their retry identity', async () => { - await createOrcadSshHost('/profile', request) - mocks.deployedTargets.push('ssh-1') - expect(listPendingOrcadSshProvisioning('/profile')).toEqual([]) - await resumeOrcadSshHost('/profile', 'request-1') - expect(mocks.add).toHaveBeenCalledOnce() - expect(mocks.deploy).toHaveBeenCalledTimes(2) - }) - - it('does not provision imported config entries without explicit intent', () => { - mocks.targets.push({ ...request.target, id: 'imported', source: 'ssh-config' }) - expect(listPendingOrcadSshProvisioning('/profile')).toEqual([]) - expect(mocks.deploy).not.toHaveBeenCalled() - }) - - it('rejects invalid input and unknown retry IDs without writing a target', async () => { - expect(() => createOrcadSshHost('/profile', { ...request, requestId: '' })).toThrow( - 'request id' - ) - expect(() => - createOrcadSshHost('/profile', { ...request, target: { ...request.target, port: 0 } }) - ).toThrow('port') - await expect(resumeOrcadSshHost('/profile', 'missing')).rejects.toThrow('not found') - expect(mocks.add).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/orcad-ssh-provisioning.ts b/src/main/ssh/orcad-ssh-provisioning.ts deleted file mode 100644 index 8b904e7f272c..000000000000 --- a/src/main/ssh/orcad-ssh-provisioning.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { z } from 'zod' -import type { - OrcadSshPendingProvisioning, - OrcadSshProvisioningRequest, - OrcadSshProvisioningResult -} from '../../shared/orcad-ssh-provisioning' -import type { SshRepoReadoption, SshTarget, SshTargetCreateInput } from '../../shared/ssh-types' -import { EphemeralVmRecipeSshTargetSchema } from '../../shared/ephemeral-vm-recipes' -import { listEnvironments } from '../../shared/runtime-environment-store' -import { normalizeSshConfigAlias } from '../../shared/ssh-config-alias' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import { normalizeSshTarget } from '../persistence/leasing-ssh-ptys/ssh-normalization' -import { requireManagedOrcadTargetStore } from './orcad-managed-runtime-context' -import { createManagedOrcadEnvironment } from './orcad-runtime-deployment' -import { rotateSshProviderAuthority } from './ssh-provider-authority' - -// Why no port forwards: a managed server is only created on an empty host. -const provisioningTargetSchema = EphemeralVmRecipeSshTargetSchema.omit({ - portForwards: true -}).extend({ - gssapiAuthentication: z.boolean().optional(), - systemSshConnectionReuse: z.boolean().optional(), - source: z.enum(['manual', 'ssh-config']).optional(), - lastRequiredPassphrase: z.boolean().optional() -}) - -/** Requests whose server is not registered yet; a completed one keeps its intent for idempotent retries. */ -export function listPendingOrcadSshProvisioning( - userDataPath: string -): OrcadSshPendingProvisioning[] { - const deployed = new Set( - listEnvironments(userDataPath).flatMap((environment) => - environment.orcadDeployment ? [environment.orcadDeployment.sshTargetId] : [] - ) - ) - return requireManagedOrcadTargetStore() - .getOrcadRuntimeClaims() - .listTargets() - .flatMap((target) => - target.orcadProvisioning && !deployed.has(target.id) - ? [{ ...target.orcadProvisioning, sshTargetId: target.id }] - : [] - ) -} - -export function createOrcadSshHost( - userDataPath: string, - request: OrcadSshProvisioningRequest -): Promise { - const requestId = requireRequestId(request?.requestId) - const name = requireText(request?.name, 'Server name') - const targetInput = parseTarget(request?.target) - return runTargetLifecycle(`orcad-provision:${userDataPath}:${requestId}`, async () => { - const targetStore = requireManagedOrcadTargetStore() - const targets = targetStore.getOrcadRuntimeClaims().listTargets() - const existing = targets.find((entry) => entry.orcadProvisioning?.requestId === requestId) - if (existing) { - if (existing.orcadProvisioning?.name !== name || !matchesRequest(existing, targetInput)) { - throw new Error('This provisioning request already belongs to another host or server name.') - } - return provision(userDataPath, existing, []) - } - if (targets.some((entry) => sameEndpoint(entry, targetInput))) { - throw new Error('That SSH host is already registered. Use its existing server or SSH entry.') - } - const target = targetStore.addTarget({ - ...targetInput, - source: 'manual', - orcadProvisioning: { requestId, name } - }) - const repoReadoptions = [...targetStore.lastRepoReadoptions] - targetStore.lastRepoReadoptions = [] - for (const targetId of new Set( - repoReadoptions.flatMap(({ oldTargetId, newTargetId }) => [oldTargetId, newTargetId]) - )) { - rotateSshProviderAuthority(targetId) - } - if (repoReadoptions.length > 0) { - // Why visible: re-adopted projects make the host non-empty; hiding it would hide them too. - targetStore.updateTarget(target.id, { orcadProvisioning: undefined }) - return { - requestId, - name, - sshTargetId: target.id, - repoReadoptions, - result: { - outcome: 'pending', - reason: - 'This host already has Orca projects, so it was restored as a direct SSH host. ' + - 'A managed server can only be created on an empty host.' - } - } - } - return provision(userDataPath, target, repoReadoptions) - }) -} - -export function resumeOrcadSshHost( - userDataPath: string, - requestIdInput: string -): Promise { - const requestId = requireRequestId(requestIdInput) - return runTargetLifecycle(`orcad-provision:${userDataPath}:${requestId}`, async () => { - const target = requireManagedOrcadTargetStore() - .getOrcadRuntimeClaims() - .listTargets() - .find((entry) => entry.orcadProvisioning?.requestId === requestId) - if (!target) { - throw new Error('The managed SSH provisioning request was not found.') - } - return provision(userDataPath, target, []) - }) -} - -async function provision( - userDataPath: string, - target: SshTarget, - repoReadoptions: SshRepoReadoption[] -): Promise { - const intent = target.orcadProvisioning - if (!intent) { - throw new Error('The managed SSH provisioning request was not found.') - } - // Why first: the intent must survive a crash before the host is contacted. - await requireManagedOrcadTargetStore().getOrcadRuntimeClaims().flush() - const base = { ...intent, sshTargetId: target.id, repoReadoptions } - try { - return { - ...base, - result: await createManagedOrcadEnvironment(userDataPath, { - name: intent.name, - sshTargetId: target.id - }) - } - } catch (error) { - return { - ...base, - result: { - outcome: 'pending', - reason: error instanceof Error ? error.message : 'Managed SSH provisioning failed.' - } - } - } -} - -function requireText(value: unknown, label: string): string { - if (typeof value !== 'string' || !value.trim() || value.length > 1_024) { - throw new Error(`${label} is required and must not exceed 1024 characters.`) - } - return value.trim() -} - -function requireRequestId(value: unknown): string { - if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(value)) { - throw new Error('A stable managed SSH provisioning request id is required.') - } - return value -} - -function parseTarget(value: unknown): SshTargetCreateInput { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('A new unowned SSH host is required.') - } - const { - id: _id, - generation: _generation, - orcadProvisioning: _intent, - owner, - ...raw - }: Record = { ...value } - if (owner !== undefined) { - throw new Error('A new unowned SSH host is required.') - } - const target = provisioningTargetSchema.parse(raw) - return { - ...target, - label: requireText(target.label, 'SSH host label'), - host: requireText(target.host, 'SSH host'), - username: target.username.trim(), - configHost: target.configHost?.trim() || target.host.trim() - } -} - -function sameEndpoint(left: SshTarget, right: SshTargetCreateInput): boolean { - const alias = normalizeSshConfigAlias(right.configHost ?? right.host) - if ( - alias && - [left.configHost, left.label].some((value) => normalizeSshConfigAlias(value) === alias) - ) { - return true - } - return left.host === right.host && left.port === right.port && left.username === right.username -} - -function matchesRequest(target: SshTarget, input: SshTargetCreateInput): boolean { - const stored: Record = { ...target } - return Object.entries(normalizeSshTarget({ ...input, id: target.id })).every( - ([key, value]) => key === 'source' || JSON.stringify(stored[key]) === JSON.stringify(value) - ) -} diff --git a/src/main/ssh/orcad-tunneled-pairing.test.ts b/src/main/ssh/orcad-tunneled-pairing.test.ts deleted file mode 100644 index ee49783ab627..000000000000 --- a/src/main/ssh/orcad-tunneled-pairing.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { encodePairingOffer, parsePairingCode, PAIRING_OFFER_VERSION } from '../../shared/pairing' -import type { ServeReadiness } from '../server/serve-readiness' -import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' - -function readiness(endpoint = 'ws://[::1]:6768/runtime?mode=paired#fragment'): ServeReadiness { - return { - runtimeId: 'runtime-1', - boundEndpoint: 'ws://127.0.0.1:6768', - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { - available: true, - url: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint, - deviceToken: 'device-token', - publicKeyB64: 'public-key', - pairedDeviceId: 'device-1' - }), - endpoint, - deviceId: 'device-1', - webClientUrl: null, - scope: 'runtime', - qr: null - } - } -} - -describe('tunneledOrcadPairingCode', () => { - it('rewrites only the endpoint authority for the local tunnel', () => { - const rewritten = parsePairingCode(tunneledOrcadPairingCode(readiness(), 46_768)) - - expect(rewritten).toEqual({ - v: PAIRING_OFFER_VERSION, - endpoint: 'ws://127.0.0.1:46768/runtime?mode=paired#fragment', - deviceToken: 'device-token', - publicKeyB64: 'public-key', - pairedDeviceId: 'device-1' - }) - }) - - it('refuses a non-loopback offer', () => { - expect(() => tunneledOrcadPairingCode(readiness('wss://runtime.example.com'), 46_768)).toThrow( - 'not loopback-only' - ) - }) -}) diff --git a/src/main/ssh/orcad-tunneled-pairing.ts b/src/main/ssh/orcad-tunneled-pairing.ts deleted file mode 100644 index 534321818845..000000000000 --- a/src/main/ssh/orcad-tunneled-pairing.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { encodePairingOffer, parsePairingCode } from '../../shared/pairing' -import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' -import type { ServeReadiness } from '../server/serve-readiness' - -export function tunneledOrcadPairingCode(readiness: ServeReadiness, localPort: number): string { - if (!readiness.pairing.available) { - throw new Error( - `The managed Orca server did not publish a pairing offer: ${readiness.pairing.guidance}` - ) - } - const offer = parsePairingCode(readiness.pairing.url) - if (!offer) { - throw new Error('The managed Orca server published an invalid pairing offer.') - } - const endpoint = new URL(offer.endpoint) - if ( - (endpoint.protocol !== 'ws:' && endpoint.protocol !== 'wss:') || - classifyRemotePairingHostname(endpoint.hostname) !== 'loopback' - ) { - throw new Error('The managed Orca server pairing endpoint is not loopback-only.') - } - endpoint.hostname = '127.0.0.1' - endpoint.port = String(localPort) - return encodePairingOffer({ ...offer, endpoint: endpoint.toString() }) -} diff --git a/src/main/ssh/runtime-ssh-access-verification.test.ts b/src/main/ssh/runtime-ssh-access-verification.test.ts deleted file mode 100644 index d847b801a231..000000000000 --- a/src/main/ssh/runtime-ssh-access-verification.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createEnvironmentFromPairingOffer } from '../../shared/runtime-environments' -import { RUNTIME_PROTOCOL_VERSION } from '../../shared/protocol-version' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' -import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' -import { verifyRuntimeEnvironmentSshTunnel } from './runtime-ssh-access-verification' - -const send = vi.hoisted(() => vi.fn()) -vi.mock('../../shared/remote-runtime-client', () => ({ sendRemoteRuntimeRequest: send })) - -const pairing = { - v: 2 as const, - endpoint: 'wss://public.example/reverse-proxy/runtime', - deviceToken: 'existing-device-token', - publicKeyB64: Buffer.alloc(32, 1).toString('base64'), - pairedDeviceId: 'existing-paired-client' -} -function environment() { - return createEnvironmentFromPairingOffer({ - id: 'environment', - name: 'Existing host', - now: 1, - offer: pairing, - runtimeId: 'host-runtime' - }) -} -function success() { - return { - id: 'status', - ok: true, - result: { - runtimeId: 'host-runtime', - rendererGraphEpoch: 1, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0, - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - deviceScope: 'runtime', - pairedDeviceId: 'existing-paired-client' - }, - _meta: { runtimeId: 'host-runtime' } - } -} - -describe('existing paired host verification over SSH', () => { - beforeEach(() => { - send.mockReset().mockResolvedValue(success()) - }) - - it('uses the native tunnel listener with the existing E2EE key and grant', async () => { - const original = environment() - const signal = new AbortController().signal - const result = await verifyRuntimeEnvironmentSshTunnel(original, 41000, signal) - expect(send).toHaveBeenCalledExactlyOnceWith( - { ...pairing, endpoint: 'ws://127.0.0.1:41000' }, - 'status.get', - undefined, - 15_000, - undefined, - signal, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - expect(result.verifiedRuntimeId).toBe('host-runtime') - expect(result.verifiedPairing).toEqual({ ...pairing, endpoint: 'ws://127.0.0.1:41000' }) - expect(original).toEqual(environment()) - }) - - it('can learn an unrecorded runtime id only after authenticated status verification', async () => { - const original = { ...environment(), runtimeId: null } - expect((await verifyRuntimeEnvironmentSshTunnel(original, 41000)).verifiedRuntimeId).toBe( - 'host-runtime' - ) - expect(original.runtimeId).toBeNull() - }) - - it.each([0, -1, 65536, 1.5, Number.NaN, Infinity])( - 'rejects invalid bound port %s before contact', - async (port) => { - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), port)).rejects.toThrow( - 'valid local port' - ) - expect(send).not.toHaveBeenCalled() - } - ) - - it.each(['host-identity', 'access-grant', 'connect'] as const)( - 'does not fallback when %s fails', - async (pairingStage) => { - const error = new RemoteRuntimeClientError('unauthorized', 'verification failed', { - pairingStage - }) - send.mockRejectedValue(error) - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).rejects.toBe(error) - expect(send).toHaveBeenCalledTimes(1) - } - ) - - it('refuses an RPC failure even when it carries the expected runtime metadata', async () => { - send.mockResolvedValue({ - id: 'status', - ok: false, - error: { code: 'unauthorized', message: 'Access revoked' }, - _meta: { runtimeId: 'host-runtime' } - }) - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).rejects.toThrow( - 'Access revoked' - ) - }) - - it.each([ - { runtimeId: 'other-host' }, - { pairedDeviceId: 'other-client' }, - { deviceScope: 'mobile' }, - { minCompatibleRuntimeClientVersion: RUNTIME_PROTOCOL_VERSION + 1 }, - { rendererGraphEpoch: -1 } - ])( - 'refuses wrong identity, grant scope, compatibility or malformed status: %j', - async (change) => { - const response = success() - send.mockResolvedValue({ ...response, result: { ...response.result, ...change } }) - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).rejects.toThrow() - } - ) - - it('refuses inconsistent runtime identity between status and its envelope', async () => { - send.mockResolvedValue({ ...success(), _meta: { runtimeId: 'different-runtime' } }) - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).rejects.toThrow( - 'runtime identity' - ) - }) - - it('accepts a compatible host that does not publish an optional paired device id', async () => { - const response = success() - send.mockResolvedValue({ - ...response, - result: { ...response.result, pairedDeviceId: undefined } - }) - expect((await verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).verifiedRuntimeId).toBe( - 'host-runtime' - ) - }) -}) diff --git a/src/main/ssh/runtime-ssh-access-verification.ts b/src/main/ssh/runtime-ssh-access-verification.ts deleted file mode 100644 index 9bf6058d1dd0..000000000000 --- a/src/main/ssh/runtime-ssh-access-verification.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' -import { verifyRuntimeEnvironmentIdentity } from '../runtime/runtime-environment-identity-verification' -import type { PairingOffer } from '../../shared/pairing' -import type { RuntimeStatus } from '../../shared/runtime-types' - -export async function verifyRuntimeEnvironmentSshTunnel( - environment: KnownRuntimeEnvironment, - localPort: number, - signal?: AbortSignal -): Promise<{ - verifiedPairing: PairingOffer - verifiedRuntimeId: string - runtimeStatus: RuntimeStatus -}> { - if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65_535) { - throw new Error('The SSH tunnel did not provide a valid local port.') - } - // The tunnel targets the native listener, not the public reverse proxy; E2EE still pins its key. - return verifyRuntimeEnvironmentIdentity(environment, { - endpoint: `ws://127.0.0.1:${localPort}`, - signal - }) -} diff --git a/src/main/ssh/runtime-ssh-access.test.ts b/src/main/ssh/runtime-ssh-access.test.ts deleted file mode 100644 index 2a7cbee781bc..000000000000 --- a/src/main/ssh/runtime-ssh-access.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { encodePairingOffer } from '../../shared/pairing' -import { - addEnvironmentFromPairingCode, - resolveEnvironment -} from '../../shared/runtime-environment-store' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import { readPersistedEnvironmentStore } from '../../shared/runtime-environment-store-file' -import { writeRuntimeEnvironmentSidecarEntry } from '../../shared/runtime-environment-sidecar' -import type { SshTarget } from '../../shared/ssh-types' - -const mocks = vi.hoisted(() => ({ - infrastructure: vi.fn(), - connect: vi.fn(), - start: vi.fn(), - close: vi.fn(), - ensure: vi.fn(), - verify: vi.fn(), - flush: vi.fn(), - preflight: vi.fn(), - hasDirectAuthority: vi.fn() -})) -vi.mock('./orcad-managed-runtime-context', () => ({ - requireManagedOrcadInfrastructure: mocks.infrastructure -})) -vi.mock('./orcad-managed-tunnel', () => ({ - startOrcadManagedTunnel: mocks.start, - closeOrcadManagedTunnel: mocks.close, - ensureOrcadManagedTunnel: mocks.ensure -})) -vi.mock('./runtime-ssh-access-verification', () => ({ - verifyRuntimeEnvironmentSshTunnel: mocks.verify -})) -vi.mock('./ssh-target-registry', () => ({ - hasRegisteredDirectSshAuthority: mocks.hasDirectAuthority -})) -import { - fingerprintRuntimeSshTarget, - linkRuntimeSshAccess, - unlinkRuntimeSshAccess -} from './runtime-ssh-access' - -const pairing = { - v: 2 as const, - endpoint: 'wss://example.test/runtime', - publicKeyB64: Buffer.alloc(32, 1).toString('base64'), - deviceToken: 'secret', - pairedDeviceId: 'client' -} - -describe('independent runtime SSH access coordinator', () => { - let userDataPath: string - let environmentId: string - let target: SshTarget - let events: string[] - const request = () => ({ - selector: environmentId, - requestId: 'request', - sshTargetId: 'target', - remotePort: 6768 - }) - const current = () => resolveEnvironment(userDataPath, environmentId) - const unlink = (requestId = 'unlink') => - unlinkRuntimeSshAccess( - userDataPath, - { selector: environmentId, requestId }, - { - invalidateTransport: () => { - events.push('invalidate') - expect(current().sshAccess).toBeUndefined() - } - } - ) - beforeEach(() => { - vi.resetAllMocks() - events = [] - userDataPath = mkdtempSync(join(tmpdir(), 'runtime-access-test-')) - environmentId = addEnvironmentFromPairingCode(userDataPath, { - name: 'server', - pairingCode: encodePairingOffer(pairing) - }).id - target = { - id: 'target', - label: 'host', - host: 'host.example', - port: 22, - username: 'user', - source: 'manual', - generation: 3 - } - mocks.preflight.mockReturnValue({ blockers: [] }) - mocks.flush.mockImplementation(async () => { - expect(current().pendingSshAccessOperation).toBeDefined() - events.push('flush') - }) - mocks.infrastructure.mockReturnValue({ - connectionManager: { connect: mocks.connect }, - claims: { - listTargets: () => [target], - preflight: mocks.preflight, - flush: mocks.flush, - claim: () => { - expect(current().pendingSshAccessOperation?.operation).toBe('link') - events.push('claim') - target = { ...target, owner: createManagedOrcadSshOwner(environmentId) } - return target - }, - release: () => { - expect(current().pendingSshAccessOperation?.operation).toBe('unlink') - events.push('release') - target = { ...target, owner: undefined } - return target - } - } - }) - mocks.connect.mockImplementation(async () => { - events.push('connect') - return {} - }) - mocks.start.mockImplementation(async () => { - events.push('tunnel') - return 41000 - }) - mocks.close.mockImplementation(async () => { - events.push('close') - }) - mocks.verify.mockImplementation(async () => { - events.push('verify') - return { - verifiedRuntimeId: 'runtime', - verifiedPairing: { ...pairing, endpoint: 'ws://127.0.0.1:41000' }, - runtimeStatus: {} - } - }) - }) - afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) - - it.each(['link', 'unlink'])( - 'refuses %s before side effects during host reconciliation', - async (operation) => { - const environments = readPersistedEnvironmentStore(userDataPath).environments - const persisted = environments.find((entry) => entry.id === environmentId) - if (!persisted) { - throw new Error('missing environment') - } - writeRuntimeEnvironmentSidecarEntry(userDataPath, environments, persisted, { - reconciliation: { - version: 1, - stage: 'prepared', - requestId: 'reconciliation', - canonicalEnvironmentId: environmentId, - runtimeId: 'host-runtime', - preparedAt: 1, - registrations: [ - { environmentId, authorityDigest: 'a'.repeat(64) }, - { environmentId: 'peer', authorityDigest: 'b'.repeat(64) } - ] - } - }) - await expect( - operation === 'link' ? linkRuntimeSshAccess(userDataPath, request()) : unlink() - ).rejects.toThrow('reconciliation') - expect(mocks.connect).not.toHaveBeenCalled() - expect(mocks.start).not.toHaveBeenCalled() - expect(mocks.close).not.toHaveBeenCalled() - expect(mocks.flush).not.toHaveBeenCalled() - } - ) - - it('persists intent then claim before connecting and publishes redacted authenticated access', async () => { - const result = await linkRuntimeSshAccess(userDataPath, request()) - expect(events).toEqual(['flush', 'claim', 'flush', 'connect', 'tunnel', 'verify']) - expect(result.sshAccess?.sshTargetId).toBe('target') - expect(result.orcadDeployment).toBeUndefined() - expect(JSON.stringify(result)).not.toContain('secret') - expect(current().pendingSshAccessOperation).toBeUndefined() - expect(mocks.preflight).toHaveBeenCalledWith('target') - }) - - it('handles lost link responses without connecting or verifying again', async () => { - const first = await linkRuntimeSshAccess(userDataPath, request()) - expect(await linkRuntimeSshAccess(userDataPath, request())).toEqual(first) - expect(mocks.verify).toHaveBeenCalledTimes(1) - expect(mocks.ensure).toHaveBeenCalledWith(userDataPath, environmentId) - }) - - it('invalidates old transport only after authenticated access is published', async () => { - const invalidateTransport = vi.fn(() => { - expect(current().sshAccess).toBeDefined() - expect(current().pendingSshAccessOperation).toBeUndefined() - }) - await linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport }) - await linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport }) - expect(invalidateTransport).toHaveBeenCalledTimes(2) - }) - - it('rejects changed request tuple after a completed link', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - await expect( - linkRuntimeSshAccess(userDataPath, { ...request(), remotePort: 6769 }) - ).rejects.toThrow('different') - expect(mocks.verify).toHaveBeenCalledTimes(1) - }) - - it('preserves the committed tunnel when transport refresh fails and retries the same link', async () => { - const invalidateTransport = vi.fn().mockRejectedValueOnce(new Error('refresh failed')) - await expect( - linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport }) - ).rejects.toThrow('refresh failed') - const committed = current() - expect(committed.sshAccess?.requestId).toBe('request') - expect(committed.pendingSshAccessOperation).toBeUndefined() - expect(target.owner).toEqual(createManagedOrcadSshOwner(environmentId)) - expect(mocks.close).not.toHaveBeenCalled() - - await linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport }) - expect(current()).toEqual(committed) - expect(mocks.ensure).toHaveBeenCalledExactlyOnceWith(userDataPath, environmentId) - expect(mocks.connect).toHaveBeenCalledTimes(1) - expect(mocks.verify).toHaveBeenCalledTimes(1) - expect(mocks.start).toHaveBeenCalledTimes(1) - expect(invalidateTransport).toHaveBeenCalledTimes(2) - expect(mocks.close).not.toHaveBeenCalled() - }) - - it('fences connection configuration changes before opening a forward', async () => { - mocks.connect.mockImplementationOnce(async () => { - target = { ...target, username: 'other' } - return {} - }) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('changed') - expect(mocks.start).not.toHaveBeenCalled() - expect(current().pendingSshAccessOperation?.operation).toBe('link') - }) - - it('restores pairing before invalidating and closes before releasing the exact claim', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - events = [] - await unlink() - expect(events).toEqual(['invalidate', 'close', 'release', 'flush']) - expect(current().endpoints[0].endpoint).toBe(pairing.endpoint) - expect(current().sshAccess).toBeUndefined() - expect(target.owner).toBeUndefined() - }) - - it('retains claim and pending intent on wrong-host proof, then permits cancellation', async () => { - mocks.verify.mockRejectedValueOnce(new Error('wrong host')) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('wrong host') - expect(current().pendingSshAccessOperation?.operation).toBe('link') - expect(current().sshAccess).toBeUndefined() - expect(target.owner).toBeDefined() - expect(mocks.close).toHaveBeenCalledTimes(1) - await unlink('request') - expect(target.owner).toBeUndefined() - }) - - it('retries verification failure with the same durable intent', async () => { - mocks.verify.mockRejectedValueOnce(new Error('unverifiable')) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow() - await linkRuntimeSshAccess(userDataPath, request()) - expect(current().sshAccess).toBeDefined() - }) - - it.each(['host', 'generation', 'owner'] as const)( - 'fences %s reassignment during authenticated proof', - async (field) => { - mocks.verify.mockImplementationOnce(async () => { - target = { - ...target, - ...(field === 'host' - ? { host: 'elsewhere' } - : field === 'generation' - ? { generation: 4 } - : { owner: createManagedOrcadSshOwner('other') }) - } - return { verifiedRuntimeId: 'runtime', verifiedPairing: pairing } - }) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('changed') - expect(current().sshAccess).toBeUndefined() - expect(mocks.close).toHaveBeenCalled() - await expect(unlink('request')).rejects.toThrow('changed') - expect(events).not.toContain('release') - } - ) - - it.each([1, 2])('never connects when durable flush %s fails', async (flushNumber) => { - if (flushNumber === 2) { - mocks.flush.mockResolvedValueOnce(undefined) - } - mocks.flush.mockRejectedValueOnce(new Error('disk full')) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('disk full') - expect(mocks.connect).not.toHaveBeenCalled() - expect(current().pendingSshAccessOperation).toBeDefined() - expect(!!target.owner).toBe(flushNumber === 2) - }) - - it('resumes unlink after release flush failed without releasing another owner', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - mocks.flush.mockRejectedValueOnce(new Error('disk full')) - await expect(unlink()).rejects.toThrow('disk full') - expect(target.owner).toBeUndefined() - await unlink() - expect(events.filter((event) => event === 'release')).toHaveLength(1) - expect(current().pendingSshAccessOperation).toBeUndefined() - }) - - it('does not release a new owner when retrying an interrupted unlink', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - mocks.flush.mockRejectedValueOnce(new Error('disk full')) - await expect(unlink()).rejects.toThrow('disk full') - target = { ...target, owner: createManagedOrcadSshOwner('other') } - await expect(unlink()).rejects.toThrow('changed') - expect(events.filter((event) => event === 'release')).toHaveLength(1) - expect(current().pendingSshAccessOperation?.operation).toBe('unlink') - }) - - it('refuses direct authority even on an already claimed retry', async () => { - mocks.verify.mockRejectedValueOnce(new Error('offline')) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow() - mocks.preflight.mockReturnValue({ - blockers: [{ code: 'orcad_migration_direct_ssh_repositories' }] - }) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow( - 'direct SSH authority' - ) - expect(mocks.connect).toHaveBeenCalledTimes(1) - }) - - it('rejects provisioning and missing durable generations without connecting', async () => { - target.orcadProvisioning = { name: 'host', requestId: 'provision' } - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('provisioning') - target.orcadProvisioning = undefined - target.generation = undefined - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('generation') - expect(mocks.connect).not.toHaveBeenCalled() - }) - - it('refuses a connected direct provider even when it has no catalog or leases', async () => { - mocks.hasDirectAuthority.mockReturnValue(true) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow( - 'Disconnect direct SSH authority' - ) - expect(mocks.connect).not.toHaveBeenCalled() - expect(current().pendingSshAccessOperation).toBeUndefined() - }) - - it('treats a lost successful unlink response as an idempotent no-op', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - const unlinked = await unlink() - const before = [...events] - expect(await unlink()).toEqual(unlinked) - expect(events).toEqual(before) - }) - - it('hashes connection identity, not display state or owner', () => { - const hash = fingerprintRuntimeSshTarget(target) - expect( - fingerprintRuntimeSshTarget({ - ...target, - label: 'renamed', - lastRequiredPassphrase: true, - owner: createManagedOrcadSshOwner('other') - }) - ).toBe(hash) - expect(fingerprintRuntimeSshTarget({ ...target, proxyCommand: 'other-proxy' })).not.toBe(hash) - }) - - it('strictly validates ports and request IDs', () => { - expect(() => linkRuntimeSshAccess(userDataPath, { ...request(), remotePort: 0 })).toThrow() - expect(() => - linkRuntimeSshAccess(userDataPath, { ...request(), requestId: '../other' }) - ).toThrow() - expect(mocks.infrastructure).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/runtime-ssh-access.ts b/src/main/ssh/runtime-ssh-access.ts deleted file mode 100644 index 6092a501052a..000000000000 --- a/src/main/ssh/runtime-ssh-access.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { createHash } from 'node:crypto' -import { - RuntimeSshAccessLinkRequestSchema, - RuntimeSshAccessUnlinkRequestSchema, - type RuntimeSshAccessLinkRequest, - type RuntimeSshAccessUnlinkRequest -} from '../../shared/runtime-ssh-access' -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import { assertRuntimeEnvironmentNotReconciling } from '../../shared/runtime-environment-reconciliation-record' -import { redactRuntimeEnvironment } from '../../shared/runtime-environments' -import { - cancelRuntimeEnvironmentSshAccessLink, - completeRuntimeEnvironmentSshAccessUnlink, - linkVerifiedRuntimeEnvironmentSshAccess, - prepareRuntimeEnvironmentSshAccessLink, - prepareRuntimeEnvironmentSshAccessUnlink -} from '../../shared/runtime-environment-ssh-access-store' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { SshTarget } from '../../shared/ssh-types' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import { requireManagedOrcadInfrastructure } from './orcad-managed-runtime-context' -import { - closeOrcadManagedTunnel, - ensureOrcadManagedTunnel, - startOrcadManagedTunnel -} from './orcad-managed-tunnel' -import { verifyRuntimeEnvironmentSshTunnel } from './runtime-ssh-access-verification' -import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' - -type TargetClaims = ReturnType['claims'] - -export function fingerprintRuntimeSshTarget(target: SshTarget): string { - return createHash('sha256') - .update( - JSON.stringify({ - host: target.host, - port: target.port, - username: target.username, - configHost: target.configHost, - identityFile: target.identityFile, - identityAgent: target.identityAgent, - identitiesOnly: target.identitiesOnly, - gssapiAuthentication: target.gssapiAuthentication, - proxyCommand: target.proxyCommand, - jumpHost: target.jumpHost, - systemSshConnectionReuse: target.systemSshConnectionReuse - }) - ) - .digest('hex') -} - -function requireTarget(claims: TargetClaims, targetId: string): SshTarget & { generation: number } { - const target = claims.listTargets().find((entry) => entry.id === targetId) - const generation = target?.generation - if (!target || generation === undefined || !Number.isSafeInteger(generation) || generation <= 0) { - throw new Error('SSH access requires an existing durable SSH target generation.') - } - if (target.orcadProvisioning) { - throw new Error('This SSH target has pending server provisioning.') - } - return { ...target, generation } -} - -function requireAccessOnlyTarget(claims: TargetClaims, targetId: string, environmentId: string) { - const target = requireTarget(claims, targetId) - if (hasRegisteredDirectSshAuthority(targetId)) { - throw new Error('Disconnect direct SSH authority before linking access to this paired server.') - } - // Omitting the environment bypasses the same-owner shortcut and rechecks direct authority. - const blockers = claims - .preflight(targetId) - .blockers.filter( - (entry) => - !( - entry.code === 'orcad_migration_target_owned' && - getManagedOrcadOwnerEnvironmentId(target.owner) === environmentId - ) - ) - if (blockers.length) { - throw new Error(`SSH access cannot claim direct SSH authority: ${blockers[0].code}`) - } - return target -} - -function requireFence( - claims: TargetClaims, - environmentId: string, - fence: { - sshTargetId: string - sshTargetGeneration: number - targetFingerprint?: string - }, - allowUnowned = false -): SshTarget { - const target = requireTarget(claims, fence.sshTargetId) - const owner = getManagedOrcadOwnerEnvironmentId(target.owner) - if ( - target.generation !== fence.sshTargetGeneration || - !fence.targetFingerprint || - fingerprintRuntimeSshTarget(target) !== fence.targetFingerprint || - (owner !== environmentId && !(allowUnowned && !target.owner)) - ) { - throw new Error('The SSH target registration, connection configuration, or owner changed.') - } - return target -} - -export function linkRuntimeSshAccess( - userDataPath: string, - input: RuntimeSshAccessLinkRequest, - options: { - signal?: AbortSignal - invalidateTransport?: (environmentId: string) => void | Promise - } = {} -) { - const args = RuntimeSshAccessLinkRequestSchema.parse(input) - const environmentId = resolveEnvironment(userDataPath, args.selector).id - return runTargetLifecycle(`runtime-ssh-access:${userDataPath}:${environmentId}`, () => - runTargetLifecycle(args.sshTargetId, async () => { - const { claims, connectionManager } = requireManagedOrcadInfrastructure() - const environment = resolveEnvironment(userDataPath, environmentId) - assertRuntimeEnvironmentNotReconciling(environment) - if (environment.orcadDeployment) { - throw new Error('Managed deployments cannot use independent SSH access.') - } - const target = requireAccessOnlyTarget(claims, args.sshTargetId, environmentId) - const fence = { - sshTargetId: target.id, - sshTargetGeneration: target.generation, - targetFingerprint: fingerprintRuntimeSshTarget(target) - } - if (environment.sshAccess) { - const access = environment.sshAccess - if ( - environment.pendingSshAccessOperation || - access.requestId !== args.requestId || - access.sshTargetId !== args.sshTargetId || - access.remotePort !== args.remotePort - ) { - throw new Error('This server already has a different SSH access request.') - } - requireFence(claims, environmentId, access) - await ensureOrcadManagedTunnel(userDataPath, environmentId) - requireFence(claims, environmentId, access) - await options.invalidateTransport?.(environmentId) - return redactRuntimeEnvironment(resolveEnvironment(userDataPath, environmentId)) - } - const prepared = prepareRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: environment, - requestId: args.requestId, - remotePort: args.remotePort, - ...fence - }) - await claims.flush() - requireAccessOnlyTarget(claims, target.id, environmentId) - requireFence(claims, environmentId, fence, true) - claims.claim(target.id, environmentId) - await claims.flush() - let tunnelStarted = false - let linkCommitted = false - try { - options.signal?.throwIfAborted() - const claimed = requireFence(claims, environmentId, fence) - const connection = await connectionManager.connect(claimed) - requireFence(claims, environmentId, fence) - tunnelStarted = true - const localPort = await startOrcadManagedTunnel( - environmentId, - claimed, - connection, - args.remotePort - ) - const proof = await verifyRuntimeEnvironmentSshTunnel(prepared, localPort, options.signal) - options.signal?.throwIfAborted() - requireAccessOnlyTarget(claims, target.id, environmentId) - requireFence(claims, environmentId, fence) - const linked = redactRuntimeEnvironment( - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - expectedEnvironment: prepared, - requestId: args.requestId, - ...proof, - tunnel: { - sshTargetId: target.id, - sshTargetGeneration: fence.sshTargetGeneration, - localPort, - remotePort: args.remotePort - } - }) - ) - linkCommitted = true - await options.invalidateTransport?.(environmentId) - return linked - } catch (error) { - if (tunnelStarted && !linkCommitted) { - await closeOrcadManagedTunnel(environmentId) - } - throw error - } - }) - ) -} - -export function unlinkRuntimeSshAccess( - userDataPath: string, - input: RuntimeSshAccessUnlinkRequest, - options: { invalidateTransport: (environmentId: string) => void | Promise } -) { - const args = RuntimeSshAccessUnlinkRequestSchema.parse(input) - const environmentId = resolveEnvironment(userDataPath, args.selector).id - return runTargetLifecycle(`runtime-ssh-access:${userDataPath}:${environmentId}`, async () => { - const environment = resolveEnvironment(userDataPath, environmentId) - assertRuntimeEnvironmentNotReconciling(environment) - if (environment.orcadDeployment) { - throw new Error('Managed deployments cannot unlink independent SSH access.') - } - const access = environment.pendingSshAccessOperation ?? environment.sshAccess - if (!access) { - return redactRuntimeEnvironment(environment) - } - return runTargetLifecycle(access.sshTargetId, async () => { - const { claims } = requireManagedOrcadInfrastructure() - requireFence(claims, environmentId, access, !!environment.pendingSshAccessOperation) - const prepared = - environment.pendingSshAccessOperation?.operation === 'link' - ? cancelRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: environment, - requestId: args.requestId - }) - : prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: environment, - requestId: args.requestId - }) - await options.invalidateTransport(environmentId) - await closeOrcadManagedTunnel(environmentId) - const target = requireFence(claims, environmentId, access, true) - if (target.owner && !claims.release(target.id, environmentId)) { - throw new Error('The SSH target owner changed before access could be released.') - } - await claims.flush() - requireFence(claims, environmentId, access, true) - return redactRuntimeEnvironment( - completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: args.requestId - }) - ) - }) - }) -} diff --git a/src/main/ssh/ssh-connection-store-orcad-ownership.test.ts b/src/main/ssh/ssh-connection-store-orcad-ownership.test.ts deleted file mode 100644 index 261c4288aa14..000000000000 --- a/src/main/ssh/ssh-connection-store-orcad-ownership.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import { isRuntimeOwnedSshTarget, SshConnectionStore } from './ssh-connection-store' -import { createMockStore } from './ssh-connection-store-test-fixture' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' - -const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ - loadUserSshConfigMock: vi.fn(), - sshConfigHostsToTargetsMock: vi.fn() -})) - -vi.mock('./ssh-config-parser', () => ({ - loadUserSshConfig: loadUserSshConfigMock, - sshConfigHostsToTargets: sshConfigHostsToTargetsMock -})) - -const base = { label: 'cluster', host: 'cluster.example.com', port: 22, username: 'dev' } - -describe('managed orcad ownership of SSH targets', () => { - let mockStore: ReturnType - let sshStore: SshConnectionStore - - beforeEach(() => { - mockStore = Object.assign(createMockStore(), emptyDependentStateStore()) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements the store methods SshConnectionStore calls. - sshStore = new SshConnectionStore(mockStore as never) - loadUserSshConfigMock.mockReset() - sshConfigHostsToTargetsMock.mockReset() - }) - - it('hides claimed and provisioning targets from direct SSH lists', () => { - const visible = sshStore.addTarget(base) - sshStore.addTarget({ - ...base, - label: 'provisioning', - orcadProvisioning: { requestId: 'r', name: 'n' } - }) - const claimed = sshStore.addTarget({ ...base, label: 'claimed' }) - sshStore.getOrcadRuntimeClaims().claim(claimed.id, 'environment-1') - - expect(sshStore.listTargets()).toEqual([visible]) - expect(sshStore.getOrcadRuntimeClaims().listTargets()).toHaveLength(3) - }) - - it('keeps ~/.ssh/config sync from rewriting a claimed config-sourced host', () => { - mockStore.addSshTarget({ - ...base, - id: 'ssh-config-host', - configHost: 'cluster', - source: 'ssh-config', - owner: createManagedOrcadSshOwner('environment-1') - }) - loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }]) - sshConfigHostsToTargetsMock.mockReturnValue([ - { ...base, id: 'tmp', configHost: 'cluster', host: '10.0.0.9', port: 2222 } - ]) - - expect(sshStore.importFromSshConfig()).toEqual([]) - expect(mockStore.updateSshTarget).not.toHaveBeenCalled() - }) - - it('treats ownership or a provisioning intent as runtime-owned', () => { - const target = { ...base, id: 'ssh-1' } - expect(isRuntimeOwnedSshTarget(target)).toBe(false) - expect( - isRuntimeOwnedSshTarget({ ...target, orcadProvisioning: { requestId: 'r', name: 'n' } }) - ).toBe(true) - expect(isRuntimeOwnedSshTarget({ ...target, owner: createManagedOrcadSshOwner('e') })).toBe( - true - ) - }) -}) diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index a329c935fee1..0d67587b920a 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -3,7 +3,6 @@ import type { SshRepoReadoption, SshTarget } from '../../shared/ssh-types' import { RUNTIME_OWNED_SSH_TARGET_ID_PREFIX } from '../../shared/execution-host' import { normalizeSshConfigAlias } from '../../shared/ssh-config-alias' import { loadUserSshConfig, sshConfigHostsToTargets } from './ssh-config-parser' -import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' import { buildRemovedSshTargetTombstone, readoptOrphanedWorkspacesForTarget @@ -85,11 +84,6 @@ export class SshConnectionStore { return next } - /** Exclusive managed-orcad ownership of a target; see ssh-target-orcad-claims. */ - getOrcadRuntimeClaims(): SshTargetOrcadClaims { - return new SshTargetOrcadClaims(this.store) - } - updateTarget(id: string, updates: Partial>): SshTarget | null { const existing = this.store.getSshTarget(id) // Why: a new runtime choice or endpoint must re-run the ladder, not replay the old rung. @@ -167,7 +161,6 @@ export class SshConnectionStore { const alias = normalizeSshConfigAlias(existing.configHost ?? existing.label) if ( existing.source === 'manual' || - isRuntimeOwnedSshTarget(existing) || (existing.source === undefined && !isLegacyConfigImportTarget(existing)) ) { manualAliases.add(alias) @@ -260,7 +253,7 @@ export function getRuntimeOwnedSshTargetId(runtimeId: string): string { } export function isRuntimeOwnedSshTarget(target: SshTarget): boolean { - return target.owner !== undefined || target.orcadProvisioning !== undefined + return target.owner?.type === 'on-demand-runtime' } function isLegacyConfigImportTarget(target: SshTarget): boolean { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index d7150af631ad..0fee9e369042 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -109,7 +109,6 @@ import { import type { FileUploadSession } from '../providers/types' import { openSshSessionChannelWithRetry, waitForSshChannelOpen } from './ssh-channel-open' import { withTimeout } from '../../shared/promise-timeout-fallback' -import { isEphemeralRuntimeSshOwner } from '../../shared/managed-orcad-ssh-owner' export type { SshConnectionCallbacks } from './ssh-connection-utils' type HostKeyTrustSources = { @@ -1373,7 +1372,7 @@ export class SshConnection { hostKeyStoreFile: boundSshHostKeyStoreFile() ?? undefined, strictHostKeyChecking: hostKeyResolved?.strictHostKeyChecking ?? 'ask', isHostKeyAlias, - isEphemeralRuntimeTarget: isEphemeralRuntimeSshOwner(this.target.owner), + isEphemeralRuntimeTarget: this.target.owner?.type === 'on-demand-runtime', siteConfigSuppressed, // A file that EXISTS and will not open is the absence of evidence, not evidence of a new // host — the entry that would have said "this key changed" may be in it. An ABSENT file is diff --git a/src/main/ssh/ssh-target-orcad-claims.test.ts b/src/main/ssh/ssh-target-orcad-claims.test.ts deleted file mode 100644 index c2849eb1685f..000000000000 --- a/src/main/ssh/ssh-target-orcad-claims.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - createManagedOrcadSshOwner, - getManagedOrcadOwnerEnvironmentId -} from '../../shared/managed-orcad-ssh-owner' -import type { SshRemotePtyLease, SshTarget } from '../../shared/ssh-types' -import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' - -type SetupOptions = { - target?: Partial - repos?: { id: string; path: string; displayName: string }[] - folders?: { id: string; name: string; folderPath: string }[] - leases?: Partial[] -} - -function setup(options: SetupOptions = {}) { - let target: SshTarget = { - id: 'ssh-1', - label: 'Builder', - host: 'builder', - port: 22, - username: 'dev', - ...options.target - } - const flush = vi.fn(async () => {}) - const rows = { connectionId: 'ssh-1' } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preflight reads only id, name, folderPath and connectionId. - const folders = (options.folders ?? []).map((f) => ({ ...f, ...rows })) as never - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preflight reads only id, path, displayName, kind and connectionId. - const repos = (options.repos ?? []).map((r) => ({ ...r, ...rows })) as never - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preflight reads only ptyId and state. - const leases = (options.leases ?? []) as never - const claims = new SshTargetOrcadClaims({ - ...emptyDependentStateStore(), - allocateSshTargetGeneration: () => 4, - flushPendingOrThrowAsync: flush, - getFolderWorkspaces: () => folders, - getRepos: () => repos, - getSshRemotePtyLeases: () => leases, - getSshTarget: (id) => (id === target.id ? target : undefined), - getSshTargets: () => [target], - updateSshTarget: (_id, updates) => (target = { ...target, ...updates }) - }) - return { claims, flush, current: () => target } -} - -describe('managed orcad SSH target claims', () => { - it('claims an empty target with a generation and a resumable provisioning intent', () => { - const { claims, current } = setup() - const claimed = claims.claim('ssh-1', 'environment-1', 'Managed') - expect(getManagedOrcadOwnerEnvironmentId(claimed.owner)).toBe('environment-1') - expect(claimed.generation).toBe(4) - expect(current().orcadProvisioning).toEqual({ requestId: 'environment-1', name: 'Managed' }) - }) - - it('is idempotent for its own environment and keeps an earlier provisioning request', () => { - const { claims } = setup({ - target: { - generation: 2, - owner: createManagedOrcadSshOwner('environment-1'), - orcadProvisioning: { requestId: 'request-1', name: 'From dialog' } - } - }) - const claimed = claims.claim('ssh-1', 'environment-1', 'Other name') - expect(claimed.generation).toBe(2) - expect(claimed.orcadProvisioning).toEqual({ requestId: 'request-1', name: 'From dialog' }) - }) - - it('claims for SSH access without recording a provisioning intent', () => { - const { claims } = setup({ target: { generation: 1 } }) - expect(claims.claim('ssh-1', 'environment-1').orcadProvisioning).toBeUndefined() - }) - - it.each<[string, SetupOptions, string]>([ - ['another owner', { target: { owner: createManagedOrcadSshOwner('other') } }, 'already owned'], - [ - 'direct SSH repositories', - { repos: [{ id: 'repo-1', path: '/srv/app', displayName: 'app' }] }, - 'repositories or folder workspaces' - ], - [ - 'folder workspaces', - { folders: [{ id: 'folder-1', name: 'scratch', folderPath: '/srv/scratch' }] }, - 'repositories or folder workspaces' - ], - [ - 'a saved terminal lease', - { leases: [{ ptyId: 'pty-1', state: 'detached' }] }, - 'terminal-lease ×1 (pty-1 (detached))' - ], - [ - 'saved port forwards', - { - target: { - portForwards: [{ localPort: 1, remoteHost: 'localhost', remotePort: 2 }] - } - }, - 'port forwards' - ] - ])('refuses a target with %s and leaves it unclaimed', (_label, options, message) => { - const { claims, current } = setup(options) - expect(claims.preflight('ssh-1').claimable).toBe(false) - expect(() => claims.claim('ssh-1', 'environment-1', 'Managed')).toThrow(message) - expect(current().orcadProvisioning).toBeUndefined() - }) - - it('counts terminated and expired leases too, since they still name this host', () => { - const { claims } = setup({ - leases: [ - { ptyId: 'a', state: 'terminated' }, - { ptyId: 'b', state: 'expired' } - ] - }) - expect(claims.preflight('ssh-1').blockers).toEqual([ - { - code: 'orcad_migration_dependent_state', - category: 'client-owned-state', - dependencies: [ - { kind: 'terminal-lease', count: 2, names: ['a (terminated)', 'b (expired)'] } - ] - } - ]) - }) - - it('reports an unknown target as a registration blocker', () => { - const { claims } = setup() - expect(claims.preflight('missing').blockers).toEqual([ - { code: 'orcad_migration_target_not_found', category: 'registration' } - ]) - }) - - it('releases only its own claim and clears the provisioning intent', () => { - const { claims, current } = setup() - claims.claim('ssh-1', 'environment-1', 'Managed') - expect(claims.release('ssh-1', 'environment-2')).toBeNull() - expect(claims.release('ssh-1', 'environment-1')).toMatchObject({ - owner: undefined, - orcadProvisioning: undefined - }) - expect(current().owner).toBeUndefined() - }) - - it('flushes without draining to a stable generation', async () => { - const { claims, flush } = setup() - await claims.flush() - expect(flush).toHaveBeenCalledWith({ signal: undefined, drainToStableGeneration: false }) - }) -}) diff --git a/src/main/ssh/ssh-target-orcad-claims.ts b/src/main/ssh/ssh-target-orcad-claims.ts deleted file mode 100644 index fc389d3c44f4..000000000000 --- a/src/main/ssh/ssh-target-orcad-claims.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Exclusive managed-orcad ownership of an SSH target. A claimed target is hidden from direct SSH - * surfaces and serves only its environment's tunnel. Only empty targets are claimable, including no - * saved sessions, automations, worktree metadata or terminal leases: moving a direct SSH host's - * state into a managed server is the catalog migration, which this does not do. - */ -import type { Store } from '../persistence' -import { - createManagedOrcadSshOwner, - getManagedOrcadOwnerEnvironmentId -} from '../../shared/managed-orcad-ssh-owner' -import type { - OrcadMigrationBlocker, - OrcadMigrationPreflight -} from '../../shared/orcad-migration-preflight' -import type { SshTarget } from '../../shared/ssh-types' -import { - collectDependentStateBlockers, - dependentStateMessage, - type DependentStateStore -} from './ssh-target-orcad-dependents' - -type ClaimStore = DependentStateStore & - Pick< - Store, - | 'allocateSshTargetGeneration' - | 'flushPendingOrThrowAsync' - | 'getFolderWorkspaces' - | 'getRepos' - | 'getSshTarget' - | 'getSshTargets' - | 'updateSshTarget' - > - -export class SshTargetOrcadClaims { - constructor(private readonly store: ClaimStore) {} - - listTargets(): SshTarget[] { - return this.store.getSshTargets() - } - - /** `environmentId` lets that environment's own claim pass; omit it to see every blocker. */ - preflight(targetId: string, environmentId?: string): OrcadMigrationPreflight { - const target = this.store.getSshTarget(targetId) - if (!target) { - return { - targetId, - targetLabel: null, - claimable: false, - blockers: [{ code: 'orcad_migration_target_not_found', category: 'registration' }] - } - } - if (environmentId && getManagedOrcadOwnerEnvironmentId(target.owner) === environmentId) { - return { targetId, targetLabel: target.label, claimable: true, blockers: [] } - } - const blockers = collectEmptyTargetBlockers(this.store, target) - return { targetId, targetLabel: target.label, claimable: blockers.length === 0, blockers } - } - - /** - * Idempotent for the same environment; the caller makes the claim durable before acting on it. - * A deploy passes its server name so an interrupted claim reads as pending provisioning. - */ - claim(targetId: string, environmentId: string, deployName?: string): SshTarget { - const blocker = this.preflight(targetId, environmentId).blockers[0] - if (blocker) { - throw new Error(orcadTargetBlockerMessage(targetId, blocker)) - } - const target = this.requireTarget(targetId) - const owned = getManagedOrcadOwnerEnvironmentId(target.owner) === environmentId - if (owned && target.generation) { - return target - } - const claimed = this.store.updateSshTarget(targetId, { - owner: createManagedOrcadSshOwner(environmentId), - generation: target.generation ?? this.store.allocateSshTargetGeneration(), - ...(deployName && !target.orcadProvisioning - ? { orcadProvisioning: { requestId: environmentId, name: deployName } } - : {}) - }) - if (!claimed) { - throw new Error(`SSH target "${targetId}" disappeared while it was being reserved.`) - } - return claimed - } - - release(targetId: string, environmentId: string): SshTarget | null { - const target = this.store.getSshTarget(targetId) - if (!target || getManagedOrcadOwnerEnvironmentId(target.owner) !== environmentId) { - return null - } - return this.store.updateSshTarget(targetId, { owner: undefined, orcadProvisioning: undefined }) - } - - /** Ownership must be on disk before a remote host acts on it. */ - flush(signal?: AbortSignal): Promise { - return this.store.flushPendingOrThrowAsync({ signal, drainToStableGeneration: false }) - } - - private requireTarget(targetId: string): SshTarget { - const target = this.store.getSshTarget(targetId) - if (!target) { - throw new Error(`SSH target "${targetId}" not found.`) - } - return target - } -} - -function collectEmptyTargetBlockers(store: ClaimStore, target: SshTarget): OrcadMigrationBlocker[] { - const blockers: OrcadMigrationBlocker[] = [] - if (target.owner) { - blockers.push({ - code: 'orcad_migration_target_owned', - category: 'exclusive-ownership', - owner: { ...target.owner } - }) - } - const repositories = store - .getRepos() - .filter((repo) => repo.connectionId === target.id) - .map(({ id, path, displayName, kind }) => ({ id, path, displayName, kind })) - if (repositories.length > 0) { - blockers.push({ - code: 'orcad_migration_direct_ssh_repositories', - category: 'drainable-static-state', - repositories - }) - } - const folderWorkspaces = store - .getFolderWorkspaces() - .filter((workspace) => workspace.connectionId === target.id) - .map(({ id, name, folderPath }) => ({ id, name, folderPath })) - if (folderWorkspaces.length > 0) { - blockers.push({ - code: 'orcad_migration_direct_ssh_folder_workspaces', - category: 'drainable-static-state', - folderWorkspaces - }) - } - if (target.portForwards?.length) { - blockers.push({ - code: 'orcad_migration_saved_port_forwards', - category: 'client-owned-state', - portForwards: target.portForwards.map((portForward) => ({ ...portForward })) - }) - } - blockers.push(...collectDependentStateBlockers(store, target.id)) - return blockers -} - -export function orcadTargetBlockerMessage( - targetId: string, - blocker: OrcadMigrationBlocker -): string { - switch (blocker.code) { - case 'orcad_migration_target_not_found': - return `SSH target "${targetId}" not found.` - case 'orcad_migration_target_owned': - return 'This SSH target is already owned by another managed runtime.' - case 'orcad_migration_direct_ssh_repositories': - case 'orcad_migration_direct_ssh_folder_workspaces': - return 'This SSH target owns repositories or folder workspaces. A managed server can only be created on a host with no direct SSH projects yet; keep this host in direct SSH mode.' - case 'orcad_migration_direct_ssh_terminal_leases': - return 'This SSH target still owns terminal sessions. Close them before converting the host.' - case 'orcad_migration_saved_port_forwards': - return 'This SSH target has saved port forwards. Remove them before converting the host.' - case 'orcad_migration_dependent_state': - return dependentStateMessage(blocker.dependencies) - case 'orcad_migration_dependency_unverifiable': - return `Orca could not read its saved ${blocker.sources.join(', ')} state, so it cannot show this SSH target is unused; the target was left in direct SSH mode.` - } -} diff --git a/src/main/ssh/ssh-target-orcad-dependents-fixture.ts b/src/main/ssh/ssh-target-orcad-dependents-fixture.ts deleted file mode 100644 index 34239d6e54f3..000000000000 --- a/src/main/ssh/ssh-target-orcad-dependents-fixture.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { DependentStateStore } from './ssh-target-orcad-dependents' - -/** A store with no client state referencing any SSH target. */ -export function emptyDependentStateStore( - overrides: Partial = {} -): DependentStateStore { - return { - getAllWorktreeMetaForHost: () => ({}), - getSshRemotePtyLeases: () => [], - getWorkspaceSession: () => getDefaultWorkspaceSession(), - getWorkspaceSessionHostIds: () => ['local'], - listAutomations: () => [], - ...overrides - } -} diff --git a/src/main/ssh/ssh-target-orcad-dependents.test.ts b/src/main/ssh/ssh-target-orcad-dependents.test.ts deleted file mode 100644 index ab663c2ed5bb..000000000000 --- a/src/main/ssh/ssh-target-orcad-dependents.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { ExecutionHostId } from '../../shared/execution-host' -import { collectDependentStateBlockers } from './ssh-target-orcad-dependents' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' -import { orcadTargetBlockerMessage } from './ssh-target-orcad-claims' - -const HOST: ExecutionHostId = 'ssh:ssh-1' - -function blockersFor(overrides: Parameters[0]) { - return collectDependentStateBlockers(emptyDependentStateStore(overrides), 'ssh-1') -} - -function messageFor(overrides: Parameters[0]): string { - const [blocker] = blockersFor(overrides) - if (!blocker) { - throw new Error('expected a blocker') - } - return orcadTargetBlockerMessage('ssh-1', blocker) -} - -describe('client state that still references an SSH target', () => { - it('finds nothing on an empty profile, or in an untouched session partition', () => { - expect(blockersFor({})).toEqual([]) - expect(blockersFor({ getWorkspaceSessionHostIds: () => ['local', HOST] })).toEqual([]) - }) - - it('blocks on the host session partition and names the fields holding state', () => { - const blockers = blockersFor({ - getWorkspaceSessionHostIds: () => ['local', HOST], - getWorkspaceSession: (hostId) => - hostId === HOST - ? { - ...getDefaultWorkspaceSession(), - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only whether the record is empty. - tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only whether the record is empty. - sleepingAgentSessionsByPaneKey: { 'tab-1:leaf': {} } as never - } - : getDefaultWorkspaceSession() - }) - expect(blockers).toEqual([ - { - code: 'orcad_migration_dependent_state', - category: 'client-owned-state', - dependencies: [ - { - kind: 'workspace-session', - count: 2, - names: ['tabsByWorktree', 'sleepingAgentSessionsByPaneKey'] - } - ] - } - ]) - }) - - it('blocks when the local session points its active workspace at the host', () => { - expect( - messageFor({ - getWorkspaceSession: () => ({ - ...getDefaultWorkspaceSession(), - activeWorkspaceExecutionHostId: HOST - }) - }) - ).toContain('workspace-session ×1 (active workspace)') - }) - - it('blocks on an automation that runs on the host, by name', () => { - const automation = { - name: 'Nightly build', - executionTargetType: 'ssh', - executionTargetId: 'ssh-1' - } - const elsewhere = { name: 'Other', executionTargetType: 'ssh', executionTargetId: 'ssh-2' } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only name and execution target. - const automations = [automation, elsewhere] as never - expect(messageFor({ listAutomations: () => automations })).toContain( - 'automation ×1 (Nightly build)' - ) - }) - - it('blocks on worktree metadata recorded for the host', () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only the keys. - const meta = { 'repo-1::/srv/app': {} } as never - expect( - messageFor({ - getAllWorktreeMetaForHost: (hostId) => (hostId === HOST ? meta : {}) - }) - ).toContain('worktree-metadata ×1 (repo-1::/srv/app)') - }) - - it('blocks on a saved terminal lease of any status', () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only ptyId and state. - const leases = [{ ptyId: 'pty-9', state: 'terminated' }] as never - expect(messageFor({ getSshRemotePtyLeases: () => leases })).toContain( - 'terminal-lease ×1 (pty-9 (terminated))' - ) - }) - - it('caps the names it reports but keeps the full count', () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only ptyId and state. - const leases = Array.from({ length: 7 }, (_, i) => ({ - ptyId: `p${i}`, - state: 'expired' - })) as never - const [blocker] = blockersFor({ getSshRemotePtyLeases: () => leases }) - expect(blocker).toMatchObject({ - dependencies: [{ kind: 'terminal-lease', count: 7, names: expect.any(Array) }] - }) - expect( - blocker?.code === 'orcad_migration_dependent_state' && blocker.dependencies[0]?.names - ).toHaveLength(5) - }) - - it('refuses as unverifiable when a store cannot be read, naming the store', () => { - const blockers = blockersFor({ - listAutomations: () => { - throw new Error('automations unreadable') - }, - getAllWorktreeMetaForHost: () => { - throw new Error('meta unreadable') - } - }) - expect(blockers).toEqual([ - { - code: 'orcad_migration_dependency_unverifiable', - category: 'live-or-unverifiable', - sources: ['automation', 'worktree-metadata'] - } - ]) - const [blocker] = blockers - expect(blocker && orcadTargetBlockerMessage('ssh-1', blocker)).toContain( - 'could not read its saved automation, worktree-metadata state' - ) - }) -}) diff --git a/src/main/ssh/ssh-target-orcad-dependents.ts b/src/main/ssh/ssh-target-orcad-dependents.ts deleted file mode 100644 index bfac28d25ce2..000000000000 --- a/src/main/ssh/ssh-target-orcad-dependents.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Client state that still references an SSH target. Until the full migration census exists, - * any of it keeps the host from being claimed: hiding the target would strand that state. - */ -import type { Store } from '../persistence' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import { toSshExecutionHostId } from '../../shared/execution-host' -import type { - OrcadMigrationBlocker, - OrcadMigrationDependency, - OrcadMigrationDependencyKind -} from '../../shared/orcad-migration-preflight' - -export type DependentStateStore = Pick< - Store, - | 'getAllWorktreeMetaForHost' - | 'getSshRemotePtyLeases' - | 'getWorkspaceSession' - | 'getWorkspaceSessionHostIds' - | 'listAutomations' -> - -const MAX_NAMES = 5 - -export function collectDependentStateBlockers( - store: DependentStateStore, - targetId: string -): OrcadMigrationBlocker[] { - const hostId = toSshExecutionHostId(targetId) - const readers: [OrcadMigrationDependencyKind, () => string[]][] = [ - ['workspace-session', () => workspaceSessionReferences(store, hostId)], - [ - 'automation', - () => - store - .listAutomations() - .filter((a) => a.executionTargetType === 'ssh' && a.executionTargetId === targetId) - .map((a) => a.name) - ], - ['worktree-metadata', () => Object.keys(store.getAllWorktreeMetaForHost(hostId))], - // Why every status: a terminated lease is still a saved record pointing at this host. - [ - 'terminal-lease', - () => store.getSshRemotePtyLeases(targetId).map((lease) => `${lease.ptyId} (${lease.state})`) - ] - ] - const dependencies: OrcadMigrationDependency[] = [] - const unreadable: OrcadMigrationDependencyKind[] = [] - for (const [kind, read] of readers) { - let names: string[] - try { - names = read() - } catch { - unreadable.push(kind) - continue - } - if (names.length > 0) { - dependencies.push({ kind, count: names.length, names: names.slice(0, MAX_NAMES) }) - } - } - const blockers: OrcadMigrationBlocker[] = [] - if (dependencies.length > 0) { - blockers.push({ - code: 'orcad_migration_dependent_state', - category: 'client-owned-state', - dependencies - }) - } - if (unreadable.length > 0) { - blockers.push({ - code: 'orcad_migration_dependency_unverifiable', - category: 'live-or-unverifiable', - sources: unreadable - }) - } - return blockers -} - -/** Non-default fields of the host's session partition, plus a local session pointed at the host. */ -function workspaceSessionReferences(store: DependentStateStore, hostId: string): string[] { - const references: string[] = [] - if (store.getWorkspaceSession().activeWorkspaceExecutionHostId === hostId) { - references.push('active workspace') - } - if (!store.getWorkspaceSessionHostIds().some((id) => id === hostId)) { - return references - } - const defaults: Record = { ...getDefaultWorkspaceSession() } - for (const [field, value] of Object.entries(store.getWorkspaceSession(hostId))) { - if (!isEmptyValue(value) && JSON.stringify(value) !== JSON.stringify(defaults[field])) { - references.push(field) - } - } - return references -} - -function isEmptyValue(value: unknown): boolean { - if (value === null || value === undefined) { - return true - } - if (Array.isArray(value)) { - return value.length === 0 - } - return typeof value === 'object' && Object.keys(value).length === 0 -} - -export function dependentStateMessage(dependencies: OrcadMigrationDependency[]): string { - const parts = dependencies.map( - ({ kind, count, names }) => `${kind} ×${count}${names?.length ? ` (${names.join(', ')})` : ''}` - ) - return ( - 'This SSH target is still referenced by saved Orca state that a managed server cannot take ' + - `over yet: ${parts.join('; ')}. Remove it, or keep this host in direct SSH mode.` - ) -} diff --git a/src/shared/managed-orcad-ssh-owner.test.ts b/src/shared/managed-orcad-ssh-owner.test.ts deleted file mode 100644 index 5d410d81accc..000000000000 --- a/src/shared/managed-orcad-ssh-owner.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - createManagedOrcadSshOwner, - getManagedOrcadOwnerEnvironmentId, - isEphemeralRuntimeSshOwner -} from './managed-orcad-ssh-owner' - -describe('managed orcad SSH ownership', () => { - it('writes a marker that older clients keep hidden as an internal target', () => { - const owner = createManagedOrcadSshOwner('environment-1') - - expect(owner).toEqual({ - type: 'on-demand-runtime', - runtimeId: 'managed-orcad:environment-1' - }) - expect(getManagedOrcadOwnerEnvironmentId(owner)).toBe('environment-1') - expect(isEphemeralRuntimeSshOwner(owner)).toBe(false) - }) - - it('keeps ordinary on-demand runtime targets ephemeral', () => { - const owner = { type: 'on-demand-runtime' as const, runtimeId: 'runtime-1' } - - expect(getManagedOrcadOwnerEnvironmentId(owner)).toBeNull() - expect(isEphemeralRuntimeSshOwner(owner)).toBe(true) - }) -}) diff --git a/src/shared/managed-orcad-ssh-owner.ts b/src/shared/managed-orcad-ssh-owner.ts deleted file mode 100644 index f3c554d715b0..000000000000 --- a/src/shared/managed-orcad-ssh-owner.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { SshTarget } from './ssh-types' - -// Why on-demand-runtime: shipped builds already hide targets with that owner, so a downgrade keeps it fenced. -const MANAGED_ORCAD_RUNTIME_ID_PREFIX = 'managed-orcad:' - -type SshTargetOwner = NonNullable - -export function createManagedOrcadSshOwner(environmentId: string): SshTargetOwner { - return { - type: 'on-demand-runtime', - runtimeId: `${MANAGED_ORCAD_RUNTIME_ID_PREFIX}${environmentId}` - } -} - -export function getManagedOrcadOwnerEnvironmentId( - owner: SshTargetOwner | undefined -): string | null { - if (!owner) { - return null - } - const environmentId = owner.runtimeId.startsWith(MANAGED_ORCAD_RUNTIME_ID_PREFIX) - ? owner.runtimeId.slice(MANAGED_ORCAD_RUNTIME_ID_PREFIX.length) - : '' - return environmentId || null -} - -export function isEphemeralRuntimeSshOwner(owner: SshTargetOwner | undefined): boolean { - return owner?.type === 'on-demand-runtime' && getManagedOrcadOwnerEnvironmentId(owner) === null -} diff --git a/src/shared/orcad-managed-runtime.ts b/src/shared/orcad-managed-runtime.ts deleted file mode 100644 index 8f070d748388..000000000000 --- a/src/shared/orcad-managed-runtime.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { PublicKnownRuntimeEnvironment } from './runtime-environments' - -export const ORCAD_MANAGED_REMOTE_PORT = 6_768 - -export type OrcadManagedDeployResult = - | { - outcome: 'created' | 'already-current' - environment: PublicKnownRuntimeEnvironment - activeVersion: string - } - | { - outcome: 'deferred' - candidateVersion: string - code: string - reason: string - /** False when force cannot make the rejected lifecycle transition safe. */ - forceable?: boolean - } - -export type OrcadManagedRuntimeStatus = { - environmentId: string - sshTargetId: string - activeVersion: string | null - previousVersion: string | null - activatedAt: string | null - rollbackAvailable: boolean - recovery: - | { - operation: 'activate' - phase: 'prepared' | 'incumbent-stopped' | 'snapshot-captured' | 'candidate-ready' - version: string - startedAt: string - } - | { - operation: 'rollback' - phase: - | 'prepared' - | 'incumbent-stopped' - | 'rescue-captured' - | 'rollback-state-restored' - | 'target-ready' - version: string - startedAt: string - } - | { - operation: 'decommission' - phase: 'prepared' | 'stop-dispatched' | 'process-exited' - version: string - startedAt: string - } - | null -} diff --git a/src/shared/orcad-migration-preflight.ts b/src/shared/orcad-migration-preflight.ts index 31b1687329ca..9a3f84d46a4f 100644 --- a/src/shared/orcad-migration-preflight.ts +++ b/src/shared/orcad-migration-preflight.ts @@ -30,8 +30,6 @@ export type OrcadMigrationDependencyKind = (typeof ORCAD_MIGRATION_DEPENDENCY_KI export type OrcadMigrationDependency = { kind: OrcadMigrationDependencyKind count: number - /** A bounded sample of what holds the reference, so the user can find and clear it. */ - names?: string[] } export type OrcadMigrationRepository = Pick @@ -76,11 +74,6 @@ export type OrcadMigrationBlocker = category: 'client-owned-state' dependencies: OrcadMigrationDependency[] } - | { - code: 'orcad_migration_dependency_unverifiable' - category: 'live-or-unverifiable' - sources: OrcadMigrationDependencyKind[] - } export type OrcadMigrationPreflight = { targetId: string diff --git a/src/shared/orcad-ssh-provisioning.ts b/src/shared/orcad-ssh-provisioning.ts deleted file mode 100644 index 11798f75e4c8..000000000000 --- a/src/shared/orcad-ssh-provisioning.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { OrcadManagedDeployResult } from './orcad-managed-runtime' -import type { SshRepoReadoption, SshTargetCreateInput } from './ssh-types' - -export type OrcadSshProvisioningIntent = Readonly<{ - requestId: string - name: string -}> - -export type OrcadSshProvisioningRequest = OrcadSshProvisioningIntent & { - target: SshTargetCreateInput -} - -export type OrcadSshPendingProvisioning = OrcadSshProvisioningIntent & { - sshTargetId: string -} - -export type OrcadSshProvisioningResult = OrcadSshPendingProvisioning & { - repoReadoptions: SshRepoReadoption[] - result: OrcadManagedDeployResult | { outcome: 'pending'; reason: string } -} diff --git a/src/shared/runtime-environment-managed-orcad-store.test.ts b/src/shared/runtime-environment-managed-orcad-store.test.ts deleted file mode 100644 index 4c5b08139acc..000000000000 --- a/src/shared/runtime-environment-managed-orcad-store.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { encodePairingOffer } from './pairing' -import { - getEnvironmentStorePath, - listEnvironments, - removeEnvironment, - updateEnvironmentFromPairingCode -} from './runtime-environment-store' -import { addManagedOrcadEnvironment } from './runtime-environment-managed-orcad-store' -import { getRuntimeEnvironmentSidecarPath } from './runtime-environment-sidecar' -import { getRuntimeSshAccess } from './runtime-environments' -import { shippedBuildRewrite } from './runtime-environment-shipped-store-fixture' - -const deployment = { - sshTargetId: 'ssh-1', - sshTargetGeneration: 4, - localPort: 46_768, - remotePort: 6_768 -} -const pairingCode = (endpoint = 'ws://127.0.0.1:46768') => - encodePairingOffer({ - v: 2, - endpoint, - deviceToken: 'private-device-token', - publicKeyB64: Buffer.alloc(32, 1).toString('base64') - }) - -describe('managed orcad environment store', () => { - let userDataPath: string - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-managed-orcad-store-')) - }) - afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) - - const add = (overrides: Partial[1]> = {}) => - addManagedOrcadEnvironment(userDataPath, { - id: 'environment-1', - name: 'Managed', - pairingCode: pairingCode(), - orcadDeployment: deployment, - now: 100, - ...overrides - }) - - it('keeps the deployment link out of the file shipped builds rewrite', () => { - const environment = add() - expect(environment.orcadDeployment).toEqual(deployment) - expect(getRuntimeSshAccess(environment)).toEqual(deployment) - const persisted = readFileSync(getEnvironmentStorePath(userDataPath), 'utf8') - expect(persisted).not.toContain('orcadDeployment') - expect(persisted).toContain('"connectionDependency":"ssh-tunnel"') - expect(readFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), 'utf8')).not.toContain( - 'private-device-token' - ) - }) - - it('survives a downgraded build rewriting orca-environments.json', () => { - add() - shippedBuildRewrite(userDataPath, (environments) => { - environments[0]!.lastUsedAt = 500 - }) - const [restored] = listEnvironments(userDataPath) - expect(restored?.orcadDeployment).toEqual(deployment) - expect(restored?.lastUsedAt).toBe(500) - }) - - it('drops the link when a downgraded build re-pairs the server elsewhere', () => { - add() - shippedBuildRewrite(userDataPath, (environments) => { - environments[0]!.pairingRevision = 200 - }) - expect(listEnvironments(userDataPath)[0]?.orcadDeployment).toBeUndefined() - }) - - it('refuses a pairing that does not point at the deployment tunnel', () => { - expect(() => add({ pairingCode: pairingCode('ws://127.0.0.1:1234') })).toThrow( - 'does not point at its SSH tunnel' - ) - expect(() => add({ pairingCode: pairingCode('wss://server.example') })).toThrow() - expect(listEnvironments(userDataPath)).toEqual([]) - }) - - it('refuses duplicate ids and names', () => { - add() - expect(() => add({ name: 'Other' })).toThrow('already exists') - expect(() => add({ id: 'environment-2' })).toThrow('already exists') - }) - - it('keeps a managed server from being removed or re-paired outside its lifecycle', () => { - add() - expect(() => removeEnvironment(userDataPath, 'Managed')).toThrow('managed by Orca over SSH') - expect(() => - updateEnvironmentFromPairingCode(userDataPath, 'Managed', { pairingCode: pairingCode() }) - ).toThrow('managed by Orca over SSH') - expect(listEnvironments(userDataPath)).toHaveLength(1) - }) -}) diff --git a/src/shared/runtime-environment-managed-orcad-store.ts b/src/shared/runtime-environment-managed-orcad-store.ts deleted file mode 100644 index 91db33218f03..000000000000 --- a/src/shared/runtime-environment-managed-orcad-store.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { parsePairingCode } from './pairing' -import { - createEnvironmentFromPairingOffer, - getPreferredLoopbackRuntimePort, - PersistedRuntimeEnvironmentSchema, - type KnownRuntimeEnvironment, - type OrcadDeploymentLink -} from './runtime-environments' -import { - readEnvironmentStore, - readPersistedEnvironmentStore, - RuntimeEnvironmentStoreError, - writeEnvironmentStore -} from './runtime-environment-store-file' -import { writeRuntimeEnvironmentSidecarEntry } from './runtime-environment-sidecar' - -/** Registers a server Orca deployed over SSH, paired through its own loopback tunnel. */ -export function addManagedOrcadEnvironment( - userDataPath: string, - args: { - id: string - name: string - pairingCode: string - orcadDeployment: OrcadDeploymentLink - now?: number - } -): KnownRuntimeEnvironment { - const offer = parsePairingCode(args.pairingCode) - if (!offer) { - throw new RuntimeEnvironmentStoreError('invalid_argument', 'Invalid managed pairing code.') - } - const known = readEnvironmentStore(userDataPath).environments - if (known.some((entry) => entry.id === args.id)) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - `A server with id "${args.id}" already exists.` - ) - } - if (known.some((entry) => entry.name === args.name)) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - `A server named "${args.name}" already exists.` - ) - } - const environment = createEnvironmentFromPairingOffer({ - id: args.id, - name: args.name, - now: args.now ?? Date.now(), - offer, - runtimeId: null, - connectionDependency: 'ssh-tunnel' - }) - if (getPreferredLoopbackRuntimePort(environment) !== args.orcadDeployment.localPort) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - 'The managed pairing does not point at its SSH tunnel.' - ) - } - const persisted = PersistedRuntimeEnvironmentSchema.parse(environment) - const environments = [ - ...readPersistedEnvironmentStore(userDataPath).environments, - persisted - ].sort((a, b) => a.name.localeCompare(b.name)) - // Sidecar first: a crash between the writes leaves an ownerless entry the next write prunes, - // never a registered server that has lost its deployment link. - writeRuntimeEnvironmentSidecarEntry(userDataPath, environments, persisted, { - orcadDeployment: args.orcadDeployment - }) - writeEnvironmentStore(userDataPath, { version: 1, environments }) - const registered = readEnvironmentStore(userDataPath).environments.find( - (entry) => entry.id === args.id - ) - if (!registered?.orcadDeployment) { - throw new RuntimeEnvironmentStoreError( - 'runtime_error', - 'The managed server was saved without its deployment link.' - ) - } - return registered -} diff --git a/src/shared/runtime-environment-shipped-store-fixture.ts b/src/shared/runtime-environment-shipped-store-fixture.ts deleted file mode 100644 index fa0be6264615..000000000000 --- a/src/shared/runtime-environment-shipped-store-fixture.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { readFileSync, writeFileSync } from 'node:fs' -import { z } from 'zod' -import { getEnvironmentStorePath } from './runtime-environment-store' - -// The environment schema v1.4.217 and v1.4.218 shipped: a plain z.object, so unknown keys are -// stripped, and every write (lastUsedAt included) rewrites the whole file. -const ShippedEnvironmentSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - createdAt: z.number().finite(), - updatedAt: z.number().finite(), - pairingRevision: z.number().finite().optional(), - pairedDeviceId: z.string().min(1).optional(), - lastUsedAt: z.number().finite().nullable(), - runtimeId: z.string().min(1).nullable(), - source: z.enum(['manual', 'ephemeral-vm']).optional(), - connectionDependency: z.literal('ssh-tunnel').optional(), - endpoints: z.array(z.object({}).passthrough()).min(1), - preferredEndpointId: z.string().min(1) -}) -const ShippedStoreSchema = z.object({ - version: z.literal(1), - environments: z.array(ShippedEnvironmentSchema) -}) - -/** Rewrites orca-environments.json the way a downgraded build does, stripping unknown keys. */ -export function shippedBuildRewrite( - userDataPath: string, - edit: (environments: z.infer[]) => void = () => {} -): void { - const path = getEnvironmentStorePath(userDataPath) - const store = ShippedStoreSchema.parse(JSON.parse(readFileSync(path, 'utf8'))) - edit(store.environments) - writeFileSync(path, JSON.stringify(store)) -} diff --git a/src/shared/runtime-environment-sidecar.test.ts b/src/shared/runtime-environment-sidecar.test.ts index c65788695a8a..0e372ddb53f0 100644 --- a/src/shared/runtime-environment-sidecar.test.ts +++ b/src/shared/runtime-environment-sidecar.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { z } from 'zod' import { encodePairingOffer } from './pairing' import { addEnvironmentFromPairingCode, @@ -19,7 +20,6 @@ import { prepareRuntimeEnvironmentSshAccessLink } from './runtime-environment-ssh-access-store' import { readPersistedEnvironmentStore } from './runtime-environment-store-file' -import { shippedBuildRewrite } from './runtime-environment-shipped-store-fixture' const pairing = { v: 2 as const, @@ -30,6 +30,27 @@ const pairing = { } const tunnel = { sshTargetId: 'target', sshTargetGeneration: 3, localPort: 41000, remotePort: 6768 } +// The environment schema v1.4.217 and v1.4.218 shipped: a plain z.object, so unknown keys are +// stripped, and every write (lastUsedAt included) rewrites the whole file. +const ShippedEnvironmentSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + createdAt: z.number().finite(), + updatedAt: z.number().finite(), + pairingRevision: z.number().finite().optional(), + pairedDeviceId: z.string().min(1).optional(), + lastUsedAt: z.number().finite().nullable(), + runtimeId: z.string().min(1).nullable(), + source: z.enum(['manual', 'ephemeral-vm']).optional(), + connectionDependency: z.literal('ssh-tunnel').optional(), + endpoints: z.array(z.object({}).passthrough()).min(1), + preferredEndpointId: z.string().min(1) +}) +const ShippedStoreSchema = z.object({ + version: z.literal(1), + environments: z.array(ShippedEnvironmentSchema) +}) + describe('runtime environment sidecar across a downgrade', () => { let userDataPath: string beforeEach(() => { @@ -61,9 +82,18 @@ describe('runtime environment sidecar across a downgrade', () => { }) } + function shippedBuildRewrite( + edit: (environments: z.infer[]) => void + ) { + const path = getEnvironmentStorePath(userDataPath) + const store = ShippedStoreSchema.parse(JSON.parse(readFileSync(path, 'utf8'))) + edit(store.environments) + writeFileSync(path, JSON.stringify(store)) + } + it('keeps SSH access after a shipped build rewrites orca-environments.json', () => { const access = linked() - shippedBuildRewrite(userDataPath, (environments) => { + shippedBuildRewrite((environments) => { environments[0]!.lastUsedAt = 500 environments[0]!.runtimeId = 'host-runtime' }) @@ -75,7 +105,7 @@ describe('runtime environment sidecar across a downgrade', () => { it('reads a link as stale once a shipped build re-pairs the server, and prunes it on the next write', () => { const access = linked() - shippedBuildRewrite(userDataPath, (environments) => { + shippedBuildRewrite((environments) => { environments[0]!.pairingRevision = 200 }) const [restored] = listEnvironments(userDataPath) @@ -92,7 +122,7 @@ describe('runtime environment sidecar across a downgrade', () => { it('ignores a dangling entry for a server a shipped build removed', () => { linked() - shippedBuildRewrite(userDataPath, (environments) => { + shippedBuildRewrite((environments) => { environments.splice(0, 1) }) expect(listEnvironments(userDataPath)).toEqual([]) diff --git a/src/shared/runtime-environment-sidecar.ts b/src/shared/runtime-environment-sidecar.ts index d421d0e87947..468517814535 100644 --- a/src/shared/runtime-environment-sidecar.ts +++ b/src/shared/runtime-environment-sidecar.ts @@ -6,7 +6,6 @@ import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' import { RuntimeEnvironmentReconciliationRecordSchema } from './runtime-environment-reconciliation-record' import { KnownRuntimeEnvironmentSchema, - OrcadDeploymentLinkSchema, RuntimeAccessEndpointSchema, RuntimeSshAccessLinkSchema, RuntimeSshAccessOperationSchema, @@ -36,8 +35,6 @@ const SidecarEntrySchema = z.object({ runtimeId: z.string().min(1).optional(), sshAccess: SidecarSshAccessSchema.optional(), pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(), - // The managed deployment's tunnel; its loopback endpoint is the persisted pairing itself. - orcadDeployment: OrcadDeploymentLinkSchema.optional(), // Keeps the overlaid pairing revision monotonic after the access that raised it is removed. pairingRevisionFloor: z.number().finite().optional(), reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional() @@ -142,7 +139,6 @@ export function overlayRuntimeEnvironmentSidecar( ...(entry.pendingSshAccessOperation ? { pendingSshAccessOperation: entry.pendingSshAccessOperation } : {}), - ...(entry.orcadDeployment ? { orcadDeployment: entry.orcadDeployment } : {}), ...(entry.reconciliation ? { reconciliation: entry.reconciliation } : {}) }) return overlaid.success ? overlaid.data : base @@ -170,7 +166,6 @@ export function writeRuntimeEnvironmentSidecarEntry( ((entry.runtimeId !== undefined && environment.runtimeId === null) || entry.sshAccess !== undefined || entry.pendingSshAccessOperation !== undefined || - entry.orcadDeployment !== undefined || entry.reconciliation !== undefined || (entry.pairingRevisionFloor ?? basePairingRevision) > basePairingRevision) if (entry && hasState) { diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index b4e2ac83d220..bf08a7276a5b 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -228,12 +228,6 @@ export function resolveEnvironmentFromStore -/** A server Orca deployed and owns over SSH; unlike sshAccess, it grants lifecycle ownership. */ -export const OrcadDeploymentLinkSchema = RuntimeSshTunnelLinkSchema -export type OrcadDeploymentLink = RuntimeSshTunnelLink - export const RuntimeSshAccessLinkSchema = RuntimeSshTunnelLinkSchema.extend({ requestId: z.string().min(1).optional(), targetFingerprint: z.string().min(1).optional(), @@ -81,12 +77,11 @@ export type PersistedRuntimeEnvironment = z.infer - !pendingSshAccessOperation || (!sshAccess && !orcadDeployment && !connectionDependency), + ({ pendingSshAccessOperation, sshAccess, connectionDependency }) => + !pendingSshAccessOperation || (!sshAccess && !connectionDependency), { message: 'Pending SSH access operations cannot coexist with active SSH access.', path: ['pendingSshAccessOperation'] @@ -112,22 +107,6 @@ export const KnownRuntimeEnvironmentSchema = PersistedRuntimeEnvironmentSchema.e path: ['sshAccess'] } ) - .refine( - (environment) => { - const deployment = environment.orcadDeployment - return ( - !deployment || - (!environment.sshAccess && - environment.connectionDependency === 'ssh-tunnel' && - getPreferredLoopbackRuntimePort(environment) === deployment.localPort) - ) - }, - { - message: - 'A managed deployment must prefer its own tunnel and exclude independent SSH access.', - path: ['orcadDeployment'] - } - ) export type KnownRuntimeEnvironment = z.infer @@ -218,11 +197,11 @@ export function getPreferredPairingOffer(environment: KnownRuntimeEnvironment): } } -/** The tunnel a server is reached through; only orcadDeployment also grants lifecycle ownership. */ +/** SSH is an access path for a paired server; managed lifecycle ownership is T6's deployment link. */ export function getRuntimeSshAccess( - environment: Pick + environment: Pick ): RuntimeSshTunnelLink | undefined { - return environment.orcadDeployment ?? environment.sshAccess + return environment.sshAccess } export function getPreferredLoopbackRuntimePort(environment: { diff --git a/src/shared/runtime-ssh-access.ts b/src/shared/runtime-ssh-access.ts deleted file mode 100644 index 167810f095a9..000000000000 --- a/src/shared/runtime-ssh-access.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from 'zod' - -const AccessRequest = z.object({ - selector: z.string().trim().min(1).max(1024), - requestId: z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/) -}) - -export const RuntimeSshAccessLinkRequestSchema = AccessRequest.extend({ - sshTargetId: z.string().trim().min(1).max(1024), - remotePort: z.number().int().min(1).max(65_535) -}).strict() -export const RuntimeSshAccessUnlinkRequestSchema = AccessRequest.strict() - -export type RuntimeSshAccessLinkRequest = z.infer -export type RuntimeSshAccessUnlinkRequest = z.infer diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 285e0e9ac500..983498f92cab 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -1,5 +1,4 @@ import type { SshPendingPtyKill } from './ssh-pending-pty-kill' -import type { OrcadSshProvisioningIntent } from './orcad-ssh-provisioning' // ─── SSH Connection Types ─────────────────────────────────────────── @@ -89,12 +88,10 @@ export type SshTarget = { * re-adopt only, so automations fenced on an old registration cannot run on a * later target that happens to reuse the id. Never advanced by connect state. */ generation?: number - /** Main-owned provisioning intent; never fall back to a relay while it exists. */ - orcadProvisioning?: OrcadSshProvisioningIntent } /** Renderer-authored target fields; registration generations are allocated and owned by main. */ -export type SshTargetCreateInput = Omit +export type SshTargetCreateInput = Omit export type SshTargetUpdateInput = Partial /** Public target identity and observed host metadata safe to mirror to a paired client. */ From 52bd063e18f0655e843b8e504eb8f660b294c2e1 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:02 -0700 Subject: [PATCH 04/26] Revert "fix(ssh): orcad GC honors the activation journal; readiness requires proven daemon coverage (#16741 T6 follow-up) (#24451)" This reverts commit d3f8c5063b1520cf78b0e2fbd3cb5a4d9330acd1. --- src/main/orcad/orcad-health.test.ts | 18 +---- src/main/orcad/orcad-health.ts | 14 ++-- src/main/ssh/orcad-active-readiness.ts | 24 ------- src/main/ssh/orcad-gc-transaction-pins.ts | 55 --------------- src/main/ssh/orcad-remote-gc.test.ts | 72 +------------------- src/main/ssh/orcad-remote-gc.ts | 14 +--- src/main/ssh/orcad-remote-primitives.test.ts | 65 ++---------------- 7 files changed, 14 insertions(+), 248 deletions(-) delete mode 100644 src/main/ssh/orcad-gc-transaction-pins.ts diff --git a/src/main/orcad/orcad-health.test.ts b/src/main/orcad/orcad-health.test.ts index 4f75c7c9025d..70e02b37e878 100644 --- a/src/main/orcad/orcad-health.test.ts +++ b/src/main/orcad/orcad-health.test.ts @@ -11,21 +11,13 @@ const { readDaemonPidRecordMock, daemonOwnsFreshPersistentPtysMock } = vi.hoisted(() => ({ - checkDaemonHealthMock: vi.fn<(socketPath: string, tokenPath: string) => Promise>(), + checkDaemonHealthMock: vi.fn<() => Promise>(), getDaemonEndpointFactsMock: vi.fn<() => unknown>(), readDaemonPidRecordMock: vi.fn<() => ParsedDaemonPid | null>(), daemonOwnsFreshPersistentPtysMock: vi.fn<() => boolean>() })) -// The real coverage fallback is per platform; a daemon may also report its own coverage. -const reportedCoverage = vi.hoisted(() => ({ value: new Array<'pty-spawn' | 'handshake'>() })) -vi.mock('../daemon/daemon-health', () => ({ - checkDaemonHealthWithCoverage: async (socketPath: string, tokenPath: string) => ({ - verdict: await checkDaemonHealthMock(socketPath, tokenPath), - coverage: - reportedCoverage.value.shift() ?? (process.platform === 'win32' ? 'handshake' : 'pty-spawn') - }) -})) +vi.mock('../daemon/daemon-health', () => ({ checkDaemonHealth: checkDaemonHealthMock })) vi.mock('../daemon/daemon-init', () => ({ getDaemonEndpointFacts: getDaemonEndpointFactsMock, readDaemonPidRecord: readDaemonPidRecordMock, @@ -131,12 +123,6 @@ describe('collectTerminalDaemonHealth', () => { // green verdict there must not be reported as a PTY round trip. expect(health.selfTest.coverage).toBe('handshake') }) - - it('reports the coverage the daemon says its probe achieved', async () => { - reportedCoverage.value.push('handshake') - const health = await collectTerminalDaemonHealth() - expect(health.selfTest).toMatchObject({ ok: true, coverage: 'handshake' }) - }) }) describe('collectOrcadHealth', () => { diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts index 6507763f3cf7..df523a7e9726 100644 --- a/src/main/orcad/orcad-health.ts +++ b/src/main/orcad/orcad-health.ts @@ -11,7 +11,7 @@ import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import process from 'node:process' -import { checkDaemonHealthWithCoverage, type DaemonHealth } from '../daemon/daemon-health' +import { checkDaemonHealth, type DaemonHealth } from '../daemon/daemon-health' import { daemonOwnsFreshPersistentPtys, getDaemonEndpointFacts, @@ -106,18 +106,14 @@ export async function runTerminalDaemonSelfTest( now: () => number = () => Date.now() ): Promise { const startedAt = now() + // Why: `checkPtySpawnHealth` returns immediately on win32 without spawning anything, so a + // green verdict there covers the handshake only. Say so instead of overclaiming. + const coverage: PtySelfTestCoverage = process.platform === 'win32' ? 'handshake' : 'pty-spawn' const facts = getDaemonEndpointFacts() if (!facts) { - // Why: `checkPtySpawnHealth` returns immediately on win32 without spawning anything, so a - // green verdict there covers the handshake only. Say so instead of overclaiming. - const coverage: PtySelfTestCoverage = process.platform === 'win32' ? 'handshake' : 'pty-spawn' return { ok: false, coverage, verdict: 'no-daemon', durationMs: now() - startedAt } } - // The daemon reports what its probe actually did; an older daemon falls back by platform. - const { verdict, coverage } = await checkDaemonHealthWithCoverage( - facts.socketPath, - facts.tokenPath - ) + const verdict = await checkDaemonHealth(facts.socketPath, facts.tokenPath) return { ok: verdict === 'healthy', coverage, verdict, durationMs: now() - startedAt } } diff --git a/src/main/ssh/orcad-active-readiness.ts b/src/main/ssh/orcad-active-readiness.ts index 5fe6c157f9b8..40beecb236ce 100644 --- a/src/main/ssh/orcad-active-readiness.ts +++ b/src/main/ssh/orcad-active-readiness.ts @@ -47,33 +47,9 @@ function gatedReadiness( `${label} passed activation without a readiness payload.` ) } - const coverage = orcadDaemonCoverageRefusal(readiness) - if (coverage) { - throw new OrcadActiveReadinessError('rejected', `${label} ${coverage}`) - } return readiness } -/** - * A slot proves terminals only when its daemon's self-test spawned a PTY, or completed the - * handshake on a platform whose daemon never spawn-probes. A build that predates the coverage - * field keeps the identity gate alone, so an older slot is not stranded. - */ -export function orcadDaemonCoverageRefusal(readiness: ServeReadiness): string | null { - const health = readiness.health - const coverage = health?.terminalDaemon?.selfTest?.coverage - if (!health || coverage === undefined || coverage === 'pty-spawn') { - return null - } - if (coverage === 'handshake' && health.platform === 'win32') { - return null - } - return ( - `reported terminal-daemon coverage '${String(coverage)}', which does not prove a PTY can ` + - `be created on ${health.platform}.` - ) -} - /** Checks a recorded-active slot without starting anything. */ export async function probeActiveOrcadReadiness( target: OrcadRemoteExecTarget & { remoteInstallDir: string }, diff --git a/src/main/ssh/orcad-gc-transaction-pins.ts b/src/main/ssh/orcad-gc-transaction-pins.ts deleted file mode 100644 index 4c90e6be434c..000000000000 --- a/src/main/ssh/orcad-gc-transaction-pins.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * What an in-flight activation, rollback or decommission still needs from GC. - * - * The journal names every slot the transaction may restart or settle; GC must keep them all. - * A held fence without a journal, or a journal this client cannot read, means a transaction - * this client cannot see into, so GC keeps everything rather than guess. - */ -import { remoteInstallDirName, ORCAD_INSTALL_MODEL } from './remote-install-model' -import type { OrcadActivationTransaction } from './orcad-activation-transaction' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { orcadActivationFenceExists } from './orcad-activation-lock' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import type { OrcadRemoteExecTarget } from './orcad-remote-runtime-control' - -export type OrcadGcTransactionPins = { state: 'pinned'; dirNames: string[] } | { state: 'keep-all' } - -function transactionVersions(transaction: OrcadActivationTransaction): (string | null)[] { - const records = [transaction.recordBefore, transaction.recordAfter] - const fromRecords = records.flatMap((record) => (record ? [record.active, record.previous] : [])) - if (transaction.operation === 'activate') { - return [...fromRecords, transaction.candidateVersion] - } - // Any other operation names its slots through its records. - return transaction.operation === 'rollback' - ? [...fromRecords, transaction.incumbentVersion, transaction.targetVersion] - : fromRecords -} - -export async function readOrcadGcTransactionPins( - target: OrcadRemoteExecTarget & { remoteHome: string } -): Promise { - try { - const transaction = await readOrcadActivationTransaction(target) - if (!transaction) { - // A fence without a journal is a transaction starting or a release cut short. - return (await orcadActivationFenceExists(target)) - ? { state: 'keep-all' } - : { state: 'pinned', dirNames: [] } - } - const versions = transactionVersions(transaction).filter( - (version): version is string => typeof version === 'string' && version.length > 0 - ) - return { - state: 'pinned', - dirNames: [...new Set(versions)].map((version) => - remoteInstallDirName(ORCAD_INSTALL_MODEL, version) - ) - } - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return { state: 'keep-all' } - } -} diff --git a/src/main/ssh/orcad-remote-gc.test.ts b/src/main/ssh/orcad-remote-gc.test.ts index 974677d45f5c..044bd974e989 100644 --- a/src/main/ssh/orcad-remote-gc.test.ts +++ b/src/main/ssh/orcad-remote-gc.test.ts @@ -23,8 +23,6 @@ vi.mock('./ssh-relay-install-lock', () => ({ import { execCommand } from './ssh-relay-deploy-helpers' import { gcOldOrcadVersions } from './orcad-remote-gc' import { emptyOrcadActivationRecord } from './orcad-activation-record' -import { serializeOrcadActivationTransaction } from './orcad-activation-transaction' -import { createOrcadActivationTransaction } from './orcad-activation-transaction-transitions' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshConnection } from './ssh-connection' @@ -41,22 +39,8 @@ function scriptHost(options: { listing: string[] liveness?: Record removed: string[] - /** The activation journal: absent by default, raw contents, or a read with no answer. */ - journal?: string | Error - fenceHeld?: boolean }): void { mockExec.mockImplementation(async (_conn, command: string) => { - if (command.includes('__ORCAD_RECORD_PRESENT__') && command.includes('transaction.json')) { - if (options.journal instanceof Error) { - throw options.journal - } - return options.journal === undefined - ? '__ORCAD_RECORD_ABSENT__\n' - : `__ORCAD_RECORD_PRESENT__\n${options.journal}` - } - if (command.includes('.orcad-activation-transaction') && command.includes('LOCKED')) { - return options.fenceHeld ? 'LOCKED' : 'OPEN' - } if (command.includes('-mindepth 1 -maxdepth 1')) { return options.listing.join('\n') } @@ -200,8 +184,7 @@ describe('orcad GC', () => { ).rejects.toBe(error) expect(removed).toEqual([]) - // Journal read and fence probe, then the GC pass up to the unconfirmed probe. - expect(mockExec).toHaveBeenCalledTimes(6) + expect(mockExec).toHaveBeenCalledTimes(4) expect(mockExec.mock.calls.at(-1)?.[1]).toContain('.orcad-pid') }) @@ -243,57 +226,4 @@ describe('orcad GC', () => { await gcOldOrcadVersions({ ...options, nodeRuntimePins: ['a'.repeat(64)] }) expect(inventories()).toBe(1) }) - - describe('with an activation transaction in flight', () => { - const listing = ['orcad-0.1.0+01d', 'orcad-0.2.0+9ee0', 'orcad-0.3.0+cc0'] - const record = { ...emptyOrcadActivationRecord(), active: '0.3.0+cc0' } - const run = () => - gcOldOrcadVersions({ - conn, - host, - remoteHome: '/home/u', - currentDirAbsPath: '/home/u/.orca-remote/orcad-0.3.0+cc0', - record - }) - - it('keeps every slot a pending journal names', async () => { - const removed: string[] = [] - const before = { ...record, previous: '0.1.0+01d' } - scriptHost({ - listing, - removed, - journal: serializeOrcadActivationTransaction( - createOrcadActivationTransaction({ - transactionId: '7f1c2a7e-6c1b-4a8e-9f0e-0a1b2c3d4e5f', - candidateVersion: '0.2.0+9ee0', - recordBefore: before, - snapshotDirName: 'pre-0.2.0+9ee0-1', - now: new Date(0) - }) - ) - }) - await run() - expect(removed).toEqual([]) - }) - - it.each([ - ['an unreadable journal', { journal: '{"schemaVersion":99}' }], - ['a journal read with no answer', { journal: new Error('lost') }], - ['a held fence without a journal', { fenceHeld: true }] - ])('collects nothing behind %s', async (_name, state) => { - const removed: string[] = [] - vi.spyOn(console, 'warn').mockImplementation(() => {}) - scriptHost({ listing, removed, ...state }) - await run() - expect(removed).toEqual([]) - expect(mockExec.mock.calls.some(([, command]) => command.includes('-mindepth 1'))).toBe(false) - }) - - it('never names a snapshot or rescue copy as a candidate', async () => { - const removed: string[] = [] - scriptHost({ listing: [...listing, 'orcad-state-snapshots'], removed }) - await run() - expect(removed).not.toContain('orcad-state-snapshots') - }) - }) }) diff --git a/src/main/ssh/orcad-remote-gc.ts b/src/main/ssh/orcad-remote-gc.ts index 5f7664664e49..2f1692201e41 100644 --- a/src/main/ssh/orcad-remote-gc.ts +++ b/src/main/ssh/orcad-remote-gc.ts @@ -10,8 +10,7 @@ * * On top of the ownership rule, orcad pins three directories that are idle-looking but * load-bearing: the active version, the rollback target, and whichever version the LIVE - * terminal daemon was forked from. Every version an in-flight activation journal names is - * pinned too, and an unreadable journal skips the pass entirely. + * terminal daemon was forked from. */ import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' @@ -26,7 +25,6 @@ import { } from './orcad-remote-launch' import type { RemoteHostPlatform } from './ssh-remote-platform' import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' -import { readOrcadGcTransactionPins } from './orcad-gc-transaction-pins' export type OrcadGcOptions = { conn: SshConnection @@ -52,11 +50,6 @@ export type OrcadGcOptions = { } export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise { - const transaction = await readOrcadGcTransactionPins(options) - if (transaction.state === 'keep-all') { - console.warn('[orcad-gc] An activation transaction is unreadable or unjournaled; skipping GC.') - return - } await gcOldRemoteInstallVersions( options.conn, ORCAD_INSTALL_MODEL, @@ -64,10 +57,7 @@ export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise options.currentDirAbsPath, options.host, { - pinnedDirNames: [ - ...orcadGcPinnedDirNames(options.record, options.liveDaemonVersion), - ...transaction.dirNames - ], + pinnedDirNames: orcadGcPinnedDirNames(options.record, options.liveDaemonVersion), isDirLive: async (dir) => { try { const probe = await execCommand( diff --git a/src/main/ssh/orcad-remote-primitives.test.ts b/src/main/ssh/orcad-remote-primitives.test.ts index 881f0db21f67..ab7e6813f987 100644 --- a/src/main/ssh/orcad-remote-primitives.test.ts +++ b/src/main/ssh/orcad-remote-primitives.test.ts @@ -23,11 +23,7 @@ import { } from './orcad-activation-record-store' import { emptyOrcadActivationRecord } from './orcad-activation-record' import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash' -import { - launchOrcadSlotAndAwaitReadiness, - OrcadActiveReadinessError, - probeActiveOrcadReadiness -} from './orcad-active-readiness' +import { OrcadActiveReadinessError, probeActiveOrcadReadiness } from './orcad-active-readiness' import { parseOrcadReadinessOutput } from './orcad-remote-launch' const mockExec = vi.mocked(execCommand) @@ -37,11 +33,7 @@ const conn: SshConnection = Object.create(null) const target = { conn, host: linux } const BUILD_HASH = 'abc123def4567890' -function readyLine( - buildHash = BUILD_HASH, - daemon: { coverage?: 'pty-spawn' | 'handshake'; platform?: string } = {} -): string { - const selfTest = { ok: true, verdict: 'healthy', durationMs: 5 } +function readyLine(buildHash = BUILD_HASH): string { return JSON.stringify({ type: 'orca_server_ready', runtimeId: 'r1', @@ -54,7 +46,7 @@ function readyLine( buildVersion: '0.2.0+bb01', nodeVersion: '24.21.0', nodeAbi: '137', - platform: daemon.platform ?? 'linux', + platform: 'linux', arch: 'x64', pid: 1, terminalDaemon: { @@ -64,10 +56,7 @@ function readyLine( buildVersion: '0.2.0+bb01', entryPath: '/x/daemon-entry.js', protocolVersion: 38, - selfTest: - 'coverage' in daemon - ? { ...selfTest, ...(daemon.coverage ? { coverage: daemon.coverage } : {}) } - : { ...selfTest, coverage: 'pty-spawn' } + selfTest: { ok: true, coverage: 'pty-spawn', verdict: 'healthy', durationMs: 5 } } } }) @@ -170,52 +159,6 @@ describe('installed build identity and readiness', () => { await expect(rejected).rejects.toBeInstanceOf(OrcadActiveReadinessError) await expect(rejected).rejects.toMatchObject({ verdict: 'rejected' }) }) - - it.each([ - ['a spawn-probed PTY', { coverage: 'pty-spawn' as const }], - [ - 'a handshake on a host whose daemon never spawn-probes', - { coverage: 'handshake' as const, platform: 'win32' } - ], - ['an older build that does not report coverage', { coverage: undefined }] - ])('accepts %s', async (_name, daemon) => { - mockExec - .mockResolvedValueOnce('LIVE') - .mockResolvedValueOnce(`${readyLine(BUILD_HASH, daemon)}\n`) - await expect(probeActiveOrcadReadiness(slot, expectation)).resolves.toMatchObject({ - runtimeId: 'r1' - }) - }) - - it('rejects handshake-only coverage on a host whose daemon should spawn a PTY', async () => { - mockExec - .mockResolvedValueOnce('LIVE') - .mockResolvedValueOnce(`${readyLine(BUILD_HASH, { coverage: 'handshake' })}\n`) - await expect(probeActiveOrcadReadiness(slot, expectation)).rejects.toMatchObject({ - verdict: 'rejected', - message: expect.stringContaining("coverage 'handshake'") - }) - }) - - it('applies the same coverage rule to a slot it launches', async () => { - mockExec - .mockResolvedValueOnce('4242') - .mockResolvedValueOnce(`${readyLine(BUILD_HASH, { coverage: 'handshake' })}\n`) - await expect( - launchOrcadSlotAndAwaitReadiness( - { ...target, readinessTimeoutMs: 1_000, sleep: async () => {} }, - { - remoteInstallDir: slot.remoteInstallDir, - nodePath: '/usr/bin/node', - fullVersion: '0.2.0+bb01', - userDataDir: '/home/u/.orca', - bindHost: '127.0.0.1', - port: 7777 - }, - expectation - ) - ).rejects.toMatchObject({ verdict: 'rejected' }) - }) }) describe('readiness parsing bounds', () => { From 3880b0a094091aaca2ae1f758f57c845cfe0d56e Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:02 -0700 Subject: [PATCH 05/26] Revert "feat(ssh): remote orcad stop by request file and journaled decommission (#16741 T6-4) (#24449)" This reverts commit 43d9b43d3f6e0255348cf2771b5af3e9fcf3e5b0. --- docs/reference/orcad-operations.md | 15 -- src/main/orcad/main-preflight-order.test.ts | 25 ++- src/main/orcad/main.ts | 10 +- .../orcad/orcad-completed-stop-receipt.ts | 10 +- src/main/orcad/orcad-health.ts | 9 +- src/main/orcad/orcad-instance-lock.ts | 11 +- .../orcad-managed-stop-cancellation.test.ts | 122 ------------- .../orcad/orcad-managed-stop-cancellation.ts | 34 ---- src/main/orcad/orcad-managed-stop-command.ts | 50 +----- .../orcad/orcad-managed-stop-completion.ts | 5 - src/main/orcad/orcad-managed-stop-decision.ts | 64 ------- src/main/orcad/orcad-managed-stop.test.ts | 13 -- src/main/orcad/orcad-stop-request-listener.ts | 5 - .../orcad-activation-crash-recovery.test.ts | 3 +- .../ssh/orcad-activation-host-test-harness.ts | 49 ------ src/main/ssh/orcad-activation-record.ts | 13 -- src/main/ssh/orcad-activation-recovery.ts | 10 +- .../orcad-activation-transaction-schema.ts | 19 -- src/main/ssh/orcad-activation-transaction.ts | 53 +----- src/main/ssh/orcad-decommission-recovery.ts | 70 -------- src/main/ssh/orcad-decommission-stop.ts | 75 -------- .../orcad-decommission-transaction.test.ts | 93 ---------- .../ssh/orcad-decommission-transaction.ts | 114 ------------ src/main/ssh/orcad-managed-remote-stop.ts | 128 -------------- .../ssh/orcad-remote-decommission.test.ts | 163 ------------------ src/main/ssh/orcad-remote-launch.ts | 3 - src/main/ssh/orcad-remote-process-control.ts | 26 +-- ...-remote-shell-commands.integration.test.ts | 41 +---- src/main/ssh/orcad-remote-stop.ts | 143 --------------- src/main/ssh/orcad-update-plan.ts | 17 +- src/shared/orcad-decommission.ts | 19 -- src/shared/orcad-stop-request.ts | 33 +--- src/shared/orcad-terminal-census.ts | 19 -- 33 files changed, 71 insertions(+), 1393 deletions(-) delete mode 100644 src/main/orcad/orcad-managed-stop-cancellation.test.ts delete mode 100644 src/main/orcad/orcad-managed-stop-cancellation.ts delete mode 100644 src/main/orcad/orcad-managed-stop-decision.ts delete mode 100644 src/main/ssh/orcad-decommission-recovery.ts delete mode 100644 src/main/ssh/orcad-decommission-stop.ts delete mode 100644 src/main/ssh/orcad-decommission-transaction.test.ts delete mode 100644 src/main/ssh/orcad-decommission-transaction.ts delete mode 100644 src/main/ssh/orcad-managed-remote-stop.ts delete mode 100644 src/main/ssh/orcad-remote-decommission.test.ts delete mode 100644 src/main/ssh/orcad-remote-stop.ts delete mode 100644 src/shared/orcad-decommission.ts delete mode 100644 src/shared/orcad-terminal-census.ts diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md index f3830db82eda..c9f7f4fee361 100644 --- a/docs/reference/orcad-operations.md +++ b/docs/reference/orcad-operations.md @@ -172,21 +172,6 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to its terminals, and orcad reopens new-terminal admission before exiting. - The completed-stop receipt records `retirement` as `retired`, `live` or `unverifiable`. If orcad exits without recording an outcome, the receipt says `unverifiable`. - - `orcad --cancel-managed-stop ''` withdraws a request orcad has not acted on. - orcad and the canceller each try to create `.decision.json` exclusively, - so exactly one wins. `canceled` means orcad keeps running and the request file is removed; - `dispatched` means orcad already began stopping, and only the completion can say how it - ended. - - A build advertises all of the above with `health.stopRequests: 1` in its readiness line. - Clients stop such a build through the slot request file and older builds with `SIGTERM`, - after corroborating the PID with readiness either way. A launch clears a slot request - that the previous process never consumed. -- **Decommissioning a managed slot.** An Orca client decommissions through the same activation - journal and fence as deploy and rollback. It refuses while the terminal census reports live - or uncounted terminals, stops the instance with a managed request that also asks to retire - the daemon, and records that no version is active only after `exited` is proven. A stop - that did not finish is cancelled; if orcad already acted on it, or the host cannot answer, - the fence stays for recovery. - **Instance lock.** `/orcad.lock` names the running orcad. A record that is unreadable, malformed or over 64 KiB is never reclaimed: orcad exits 78 until an operator removes it. A shutdown whose teardown failed keeps the lock until the process exits, so a diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts index 4f1f69763b95..0b477db1a7eb 100644 --- a/src/main/orcad/main-preflight-order.test.ts +++ b/src/main/orcad/main-preflight-order.test.ts @@ -3,10 +3,7 @@ import { ORCAD_PROFILE_PREFLIGHT_FLAG, ORCAD_STARTUP_PREFLIGHT_FLAG } from '../../shared/orcad-profile-preflight' -import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, - ORCAD_COMPLETE_MANAGED_STOP_FLAG -} from '../../shared/orcad-stop-request' +import { ORCAD_COMPLETE_MANAGED_STOP_FLAG } from '../../shared/orcad-stop-request' /** * The precondition is only worth anything if it runs first. A loader failure is not @@ -78,14 +75,16 @@ describe('orcad entry', () => { expect(order).toEqual(['profile-admission', 'preflight', 'main']) }) - it.each([ORCAD_COMPLETE_MANAGED_STOP_FLAG, ORCAD_CANCEL_MANAGED_STOP_FLAG])( - 'runs %s without preflights, the bundled handoff, or a runtime', - async (flag) => { - vi.spyOn(process, 'argv', 'get').mockReturnValue(['runtime', 'orcad.js', flag, '{}']) - await import('./main') - await vi.waitFor(() => expect(order).toHaveLength(1)) + it('completes a managed stop without preflights, the bundled handoff, or a runtime', async () => { + vi.spyOn(process, 'argv', 'get').mockReturnValue([ + 'runtime', + 'orcad.js', + ORCAD_COMPLETE_MANAGED_STOP_FLAG, + '{}' + ]) + await import('./main') + await vi.waitFor(() => expect(order).toHaveLength(1)) - expect(order).toEqual([`managed-stop:${flag} {}`]) - } - ) + expect(order).toEqual([`managed-stop:${ORCAD_COMPLETE_MANAGED_STOP_FLAG} {}`]) + }) }) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 211fc94540d3..e91ee01e8fac 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -8,10 +8,7 @@ import { } from '../../shared/orcad-profile-preflight' import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight' import { handoffToBundledOrcad } from './orcad-bundled-runtime' -import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, - ORCAD_COMPLETE_MANAGED_STOP_FLAG -} from '../../shared/orcad-stop-request' +import { ORCAD_COMPLETE_MANAGED_STOP_FLAG } from '../../shared/orcad-stop-request' // Why exit before the preflight: reaching this line means the whole module graph resolved // under plain Node, which is all the build guard needs to prove. Probing natives or @@ -35,10 +32,7 @@ function failStartup(error: unknown): void { } // Why before the bundled handoff and preflights: completing a stop must not start a runtime. -if ( - process.argv[2] === ORCAD_COMPLETE_MANAGED_STOP_FLAG || - process.argv[2] === ORCAD_CANCEL_MANAGED_STOP_FLAG -) { +if (process.argv[2] === ORCAD_COMPLETE_MANAGED_STOP_FLAG) { void import('./orcad-managed-stop-command').then(({ runOrcadManagedStopCommandAndExit }) => runOrcadManagedStopCommandAndExit(process.argv.slice(2)) ) diff --git a/src/main/orcad/orcad-completed-stop-receipt.ts b/src/main/orcad/orcad-completed-stop-receipt.ts index aa89cce87d58..bf1ffc070b30 100644 --- a/src/main/orcad/orcad-completed-stop-receipt.ts +++ b/src/main/orcad/orcad-completed-stop-receipt.ts @@ -16,11 +16,11 @@ import { const RECEIPT_MAX_BYTES = 64 * 1024 -export type ReceiptKind = 'completed' | 'retirement' | 'decision' +type ReceiptKind = 'completed' | 'retirement' export function orcadStopReceiptPath(request: OrcadManagedStopRequest, kind: ReceiptKind): string { const { transactionId, instance } = OrcadManagedStopRequestSchema.parse(request) - const suffix = kind === 'completed' ? '' : `.${kind}` + const suffix = kind === 'completed' ? '' : '.retirement' return join( dirname(instance.lockPath), ORCAD_STOP_RECEIPTS_DIRNAME, @@ -29,7 +29,7 @@ export function orcadStopReceiptPath(request: OrcadManagedStopRequest, kind: Rec } /** `null` when absent; a receipt for a different request throws rather than reading as absent. */ -export function readOrcadStopReceipt( +function readReceipt( request: OrcadManagedStopRequest, kind: ReceiptKind, schema: z.ZodType @@ -64,7 +64,7 @@ function writeReceipt(request: OrcadManagedStopRequest, kind: ReceiptKind, recei export function readOrcadCompletedStopReceipt( request: OrcadManagedStopRequest ): OrcadCompletedStopReceipt | null { - return readOrcadStopReceipt(request, 'completed', OrcadCompletedStopReceiptSchema) + return readReceipt(request, 'completed', OrcadCompletedStopReceiptSchema) } /** @@ -93,7 +93,7 @@ export function persistOrcadCompletedStopReceipt( export function readOrcadDaemonRetirementRecord( request: OrcadManagedStopRequest ): OrcadDaemonRetirementRecord | null { - return readOrcadStopReceipt(request, 'retirement', OrcadDaemonRetirementRecordSchema) + return readReceipt(request, 'retirement', OrcadDaemonRetirementRecordSchema) } export function persistOrcadDaemonRetirementRecord( diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts index df523a7e9726..0c3a3525171f 100644 --- a/src/main/orcad/orcad-health.ts +++ b/src/main/orcad/orcad-health.ts @@ -18,7 +18,6 @@ import { readDaemonPidRecord } from '../daemon/daemon-init' import type { OrcadProfileStateAuthoritySelection } from './orcad-profile-state-telemetry' -import { ORCAD_STOP_REQUESTS_CAPABILITY } from '../../shared/orcad-stop-request' /** * How much a green self-test actually proves. @@ -68,11 +67,6 @@ export type OrcadHealth = { terminalDaemon: TerminalDaemonHealth /** The low-cardinality profile-state authority selected during startup, when available. */ profileStateAuthority?: OrcadProfileStateAuthoritySelection - /** - * Present when this build consumes stop-request files and answers the managed-stop commands. - * Absent on older builds, which a client must keep stopping with SIGTERM. - */ - stopRequests?: typeof ORCAD_STOP_REQUESTS_CAPABILITY } /** @@ -168,7 +162,6 @@ export async function collectOrcadHealth( arch: process.arch, pid: process.pid, terminalDaemon: await collectTerminalDaemonHealth(), - ...(profileStateAuthority ? { profileStateAuthority } : {}), - stopRequests: ORCAD_STOP_REQUESTS_CAPABILITY + ...(profileStateAuthority ? { profileStateAuthority } : {}) } } diff --git a/src/main/orcad/orcad-instance-lock.ts b/src/main/orcad/orcad-instance-lock.ts index 355835262567..6e913b5cb22f 100644 --- a/src/main/orcad/orcad-instance-lock.ts +++ b/src/main/orcad/orcad-instance-lock.ts @@ -66,7 +66,7 @@ export type OrcadLockRecord = z.infer /** `null` when absent, unreadable, oversized or malformed; callers must not read that as free. */ export function readOrcadInstanceLockRecord(path: string): OrcadLockRecord | null { - return parseOrcadInstanceLockRecord(readBoundedLockFile(path) ?? '') + return parseLockRecord(readBoundedLockFile(path) ?? '') } export type OrcadInstanceLock = { @@ -106,8 +106,7 @@ function isErrorCode(error: unknown, code: string): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === code } -/** `null` for anything that is not a complete record; never a reason to treat a lock as free. */ -export function parseOrcadInstanceLockRecord(content: string): OrcadLockRecord | null { +function parseLockRecord(content: string): OrcadLockRecord | null { try { const result = OrcadLockRecordSchema.safeParse(JSON.parse(content)) return result.success ? result.data : null @@ -230,7 +229,7 @@ export function acquireOrcadInstanceLock( return makeLock(lockPath, record) } - const existing = parseOrcadInstanceLockRecord(readBoundedLockFile(lockPath) ?? '') + const existing = parseLockRecord(readBoundedLockFile(lockPath) ?? '') if (!existing) { // Why fail closed: an unreadable record proves nothing about its holder having exited. throw new OrcadInstanceLockError( @@ -271,7 +270,7 @@ export function acquireOrcadInstanceLock( } // A contender may have replaced the entry after the liveness check; never displace its record. const claimedContents = readBoundedLockFile(claimPath) - if (parseOrcadInstanceLockRecord(claimedContents ?? '')?.nonce !== existing.nonce) { + if (parseLockRecord(claimedContents ?? '')?.nonce !== existing.nonce) { restoreDisplacedLock(claimPath, lockPath, claimedContents) throw new OrcadInstanceLockError( 'orcad_instance_lock_held', @@ -342,7 +341,7 @@ function makeLock(lockPath: string, record: OrcadLockRecord): OrcadInstanceLock // Why re-read before unlinking: a reclaim by a later orcad (after, say, a SIGKILL that // this process somehow survived enough to run handlers) leaves a record that is not // ours. Deleting it would unlock a live runtime. - const current = parseOrcadInstanceLockRecord(readBoundedLockFile(lockPath) ?? '') + const current = parseLockRecord(readBoundedLockFile(lockPath) ?? '') if (!current || current.nonce !== record.nonce) { return } diff --git a/src/main/orcad/orcad-managed-stop-cancellation.test.ts b/src/main/orcad/orcad-managed-stop-cancellation.test.ts deleted file mode 100644 index 3a19b1eed690..000000000000 --- a/src/main/orcad/orcad-managed-stop-cancellation.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, - OrcadManagedStopCancellationSchema, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { acquireOrcadInstanceLock } from './orcad-instance-lock' -import { orcadManagedStopRequestPath } from './orcad-managed-stop-request' -import { cancelOrcadManagedStop } from './orcad-managed-stop-cancellation' -import { - claimOrcadManagedStopDecision, - readOrcadManagedStopDecision -} from './orcad-managed-stop-decision' -import { completeOrcadManagedStop } from './orcad-managed-stop-completion' -import { runOrcadManagedStopCancelCommand } from './orcad-managed-stop-command' -import { installOrcadStopRequestListeners } from './orcad-stop-request-listener' - -const roots: string[] = [] -afterEach(() => { - vi.restoreAllMocks() - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function running(): OrcadManagedStopRequest { - const root = mkdtempSync(join(tmpdir(), 'orcad-stop-cancel-')) - roots.push(root) - const lock = acquireOrcadInstanceLock(root, { identity: () => 'uid-1000', startedAtMs: () => 5 }) - const { pid, startedAtMs, nonce } = lock.record - return { - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - version: '1.0.0', - runtimeId: 'runtime-1', - instance: { pid, startedAtMs, nonce, lockPath: lock.path } - } -} - -describe('cancelling a managed stop', () => { - it('lets exactly one side decide a transaction', () => { - const request = running() - expect(claimOrcadManagedStopDecision(request, 'canceled')).toBe('canceled') - expect(claimOrcadManagedStopDecision(request, 'dispatched')).toBe('canceled') - expect(readOrcadManagedStopDecision(request)).toBe('canceled') - }) - - it('withdraws a request orcad has not acted on, and removes its file', () => { - const request = running() - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(request)) - expect(cancelOrcadManagedStop(request)).toBe('canceled') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(false) - }) - - it('reports dispatched once orcad acted first, and leaves its request in place', () => { - const request = running() - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(request)) - expect(claimOrcadManagedStopDecision(request, 'dispatched')).toBe('dispatched') - expect(cancelOrcadManagedStop(request)).toBe('dispatched') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(true) - }) - - it('never removes another transaction pending for the same instance', () => { - const request = running() - const other = { ...request, transactionId: '5a7e1f0c-3b2d-4e6f-9a8b-7c6d5e4f3a2b' } - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(other)) - expect(cancelOrcadManagedStop(request)).toBe('canceled') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(true) - }) - - it('does not reissue a cancelled transaction while orcad keeps running', async () => { - const request = running() - cancelOrcadManagedStop(request) - expect( - await completeOrcadManagedStop(request, { - probeProcess: () => 'alive', - startedAtMs: () => 5, - sleep: async () => {} - }) - ).toBe('live') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(false) - }) - - it('keeps orcad running when its listener meets a cancelled request', async () => { - const request = running() - cancelOrcadManagedStop(request) - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(request)) - const onRequest = vi.fn() - const report = vi.spyOn(console, 'error').mockImplementation(() => {}) - const root = mkdtempSync(join(tmpdir(), 'orcad-stop-cancel-slot-')) - roots.push(root) - const listener = installOrcadStopRequestListeners(onRequest, { - installRoot: root, - managedStop: { - version: request.version, - runtimeId: request.runtimeId, - instance: request.instance - }, - pollIntervalMs: 10 - }) - await vi.waitFor(() => expect(report).toHaveBeenCalled()) - listener.close() - expect(onRequest).not.toHaveBeenCalled() - expect(String(report.mock.calls[0]?.[1])).toContain('orcad_managed_stop_canceled') - }) - - it('prints one cancellation line through the command', () => { - const request = running() - const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - const cancellation = runOrcadManagedStopCancelCommand([ - ORCAD_CANCEL_MANAGED_STOP_FLAG, - JSON.stringify(request) - ]) - expect(cancellation.outcome).toBe('canceled') - expect( - OrcadManagedStopCancellationSchema.parse(JSON.parse(String(write.mock.calls[0]?.[0]))) - ).toEqual(cancellation) - }) -}) diff --git a/src/main/orcad/orcad-managed-stop-cancellation.ts b/src/main/orcad/orcad-managed-stop-cancellation.ts deleted file mode 100644 index e4e0b4687374..000000000000 --- a/src/main/orcad/orcad-managed-stop-cancellation.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** Withdrawing a managed stop request that the running orcad has not acted on yet. */ -import { rmSync } from 'node:fs' -import type { - OrcadManagedStopCancellation, - OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { claimOrcadManagedStopDecision } from './orcad-managed-stop-decision' -import { - orcadManagedStopRequestPath, - readOrcadManagedStopRequest -} from './orcad-managed-stop-request' - -/** `dispatched` means orcad already began stopping; only its completion can say how it ended. */ -export function cancelOrcadManagedStop( - request: OrcadManagedStopRequest -): OrcadManagedStopCancellation['outcome'] { - const outcome = claimOrcadManagedStopDecision(request, 'canceled') - if (outcome === 'canceled') { - removePendingRequest(request) - } - return outcome -} - -/** Removes the request file only while it still carries this transaction. */ -function removePendingRequest(request: OrcadManagedStopRequest): void { - const path = orcadManagedStopRequestPath(request.instance) - try { - if (readOrcadManagedStopRequest(path).transactionId === request.transactionId) { - rmSync(path, { force: true }) - } - } catch { - // Absent or another transaction's request: the standing decision already fences this one. - } -} diff --git a/src/main/orcad/orcad-managed-stop-command.ts b/src/main/orcad/orcad-managed-stop-command.ts index b62fa2b2311c..d6f05fdd6ee0 100644 --- a/src/main/orcad/orcad-managed-stop-command.ts +++ b/src/main/orcad/orcad-managed-stop-command.ts @@ -1,20 +1,14 @@ /** * `orcad --complete-managed-stop `: one stop, one JSON verdict line on stdout. - * `orcad --cancel-managed-stop `: one cancellation, one JSON outcome line. * - * Exit 0 means a result was printed — read it, the exit code does not carry it. 64 is a - * malformed invocation; 1 is a failure before any result, which is never evidence of exit. + * Exit 0 means a verdict was printed — read it, the exit code does not carry it. 64 is a + * malformed invocation; 1 is a failure before any verdict, which is never evidence of exit. */ import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, ORCAD_COMPLETE_MANAGED_STOP_FLAG, OrcadManagedStopRequestSchema, - type OrcadManagedStopCancellation, - type OrcadManagedStopCompletion, - type OrcadManagedStopRequest + type OrcadManagedStopCompletion } from '../../shared/orcad-stop-request' -import { cancelOrcadManagedStop } from './orcad-managed-stop-cancellation' -import { readOrcadCompletedStopReceipt } from './orcad-completed-stop-receipt' import { ZodError } from 'zod' import { completeOrcadManagedStop, @@ -25,43 +19,21 @@ export async function runOrcadManagedStopCommand( argv: readonly string[], options: OrcadManagedStopCompletionOptions = {} ): Promise { - const request = parseRequestArgument(argv, ORCAD_COMPLETE_MANAGED_STOP_FLAG) + if (argv.length !== 2 || argv[0] !== ORCAD_COMPLETE_MANAGED_STOP_FLAG || !argv[1]) { + throw new Error('orcad_managed_stop_invalid_arguments') + } + const request = OrcadManagedStopRequestSchema.parse(JSON.parse(argv[1])) const verdict = await completeOrcadManagedStop(request, options) - const retirement = - verdict === 'exited' && request.retireIdleDaemon - ? readOrcadCompletedStopReceipt(request)?.retirement - : undefined const completion: OrcadManagedStopCompletion = { ...request, kind: 'orcad_managed_stop_completion', verdict, - receiptPersisted: verdict === 'exited', - ...(retirement ? { retirement } : {}) + receiptPersisted: verdict === 'exited' } process.stdout.write(`${JSON.stringify(completion)}\n`) return completion } -export function runOrcadManagedStopCancelCommand( - argv: readonly string[] -): OrcadManagedStopCancellation { - const request = parseRequestArgument(argv, ORCAD_CANCEL_MANAGED_STOP_FLAG) - const cancellation: OrcadManagedStopCancellation = { - ...request, - kind: 'orcad_managed_stop_cancellation', - outcome: cancelOrcadManagedStop(request) - } - process.stdout.write(`${JSON.stringify(cancellation)}\n`) - return cancellation -} - -function parseRequestArgument(argv: readonly string[], flag: string): OrcadManagedStopRequest { - if (argv.length !== 2 || argv[0] !== flag || !argv[1]) { - throw new Error('orcad_managed_stop_invalid_arguments') - } - return OrcadManagedStopRequestSchema.parse(JSON.parse(argv[1])) -} - export const ORCAD_MANAGED_STOP_EXIT_VERDICT = 0 export const ORCAD_MANAGED_STOP_EXIT_FAILED = 1 export const ORCAD_MANAGED_STOP_EXIT_USAGE = 64 @@ -69,11 +41,7 @@ export const ORCAD_MANAGED_STOP_EXIT_USAGE = 64 export async function runOrcadManagedStopCommandAndExit(argv: readonly string[]): Promise { let code = ORCAD_MANAGED_STOP_EXIT_VERDICT try { - if (argv[0] === ORCAD_CANCEL_MANAGED_STOP_FLAG) { - runOrcadManagedStopCancelCommand(argv) - } else { - await runOrcadManagedStopCommand(argv) - } + await runOrcadManagedStopCommand(argv) } catch (error) { console.error('orcad: managed stop failed:', error) code = diff --git a/src/main/orcad/orcad-managed-stop-completion.ts b/src/main/orcad/orcad-managed-stop-completion.ts index 0327e4772dc1..0cdde251e364 100644 --- a/src/main/orcad/orcad-managed-stop-completion.ts +++ b/src/main/orcad/orcad-managed-stop-completion.ts @@ -20,7 +20,6 @@ import { startTimesWithinTolerance } from '../daemon/daemon-process-start-time' import { persistOrcadCompletedStopReceipt } from './orcad-completed-stop-receipt' -import { readOrcadManagedStopDecision } from './orcad-managed-stop-decision' import { orcadInstanceLockNames, orcadManagedStopRequestPath, @@ -90,10 +89,6 @@ export async function completeOrcadManagedStop( // The process lives but no longer owns the lock it published: it is not ours to address. return 'unverifiable' } - if (readOrcadManagedStopDecision(request) === 'canceled') { - // A cancelled transaction is never reissued; its orcad keeps running. - return 'live' - } const requestPath = orcadManagedStopRequestPath(request.instance) if (!existingRequestMatches(requestPath, request)) { return 'unverifiable' diff --git a/src/main/orcad/orcad-managed-stop-decision.ts b/src/main/orcad/orcad-managed-stop-decision.ts deleted file mode 100644 index e6b9d3d029ef..000000000000 --- a/src/main/orcad/orcad-managed-stop-decision.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Arbitrating a managed request between the orcad acting on it and a client cancelling it. - * - * Both sides race to create one decision file per transaction with a hard link, which fails if - * the file exists. Exactly one wins, and the loser reads the winner's decision, so a cancel can - * never report success for a stop that orcad already began. - */ -import { linkSync, rmSync } from 'node:fs' -import { randomUUID } from 'node:crypto' -import { writeDurableSecureJsonFile } from '../../shared/secure-file' -import { - OrcadManagedStopDecisionSchema, - OrcadManagedStopRequestSchema, - type OrcadManagedStopDecision, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { orcadStopReceiptPath, readOrcadStopReceipt } from './orcad-completed-stop-receipt' - -export type OrcadManagedStopDecisionValue = OrcadManagedStopDecision['decision'] - -function isAlreadyDecided(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST' -} - -export function readOrcadManagedStopDecision( - request: OrcadManagedStopRequest -): OrcadManagedStopDecisionValue | null { - return readOrcadStopReceipt(request, 'decision', OrcadManagedStopDecisionSchema)?.decision ?? null -} - -/** Returns the decision that stands: ours if we created it first, otherwise the other side's. */ -export function claimOrcadManagedStopDecision( - input: OrcadManagedStopRequest, - decision: OrcadManagedStopDecisionValue -): OrcadManagedStopDecisionValue { - const request = OrcadManagedStopRequestSchema.parse(input) - const path = orcadStopReceiptPath(request, 'decision') - const staged = `${path}.${process.pid}.${randomUUID()}.staged` - const record: OrcadManagedStopDecision = { - schemaVersion: 1, - kind: 'orcad_managed_stop_decision', - request, - decision - } - if (!writeDurableSecureJsonFile(staged, record)) { - rmSync(staged, { force: true }) - throw new Error('orcad_managed_stop_decision_permissions_unconfirmed') - } - try { - linkSync(staged, path) - return decision - } catch (error) { - if (!isAlreadyDecided(error)) { - throw error - } - const standing = readOrcadManagedStopDecision(request) - if (!standing) { - throw new Error('orcad_managed_stop_decision_unverifiable') - } - return standing - } finally { - rmSync(staged, { force: true }) - } -} diff --git a/src/main/orcad/orcad-managed-stop.test.ts b/src/main/orcad/orcad-managed-stop.test.ts index c6f19bcf0e81..21bf8375ede8 100644 --- a/src/main/orcad/orcad-managed-stop.test.ts +++ b/src/main/orcad/orcad-managed-stop.test.ts @@ -238,17 +238,4 @@ describe('managed stops that retire the daemon', () => { await completeOrcadManagedStop(request, options({ probeProcess: () => 'missing' })) expect(readOrcadCompletedStopReceipt(request)).not.toHaveProperty('retirement') }) - - it('reports the recorded retirement in the completion line, so a client need not read files', async () => { - const { request } = running() - const retireRequest = { ...request, retireIdleDaemon: true as const } - await prepareOrcadManagedStop(retireRequest, outcome('live', 1)) - vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - expect( - await runOrcadManagedStopCommand( - ['--complete-managed-stop', JSON.stringify(retireRequest)], - options({ probeProcess: () => 'missing' }) - ) - ).toMatchObject({ verdict: 'exited', retirement: 'live' }) - }) }) diff --git a/src/main/orcad/orcad-stop-request-listener.ts b/src/main/orcad/orcad-stop-request-listener.ts index 1b09eeed3dca..54ddeb9456d6 100644 --- a/src/main/orcad/orcad-stop-request-listener.ts +++ b/src/main/orcad/orcad-stop-request-listener.ts @@ -16,7 +16,6 @@ import { orcadManagedStopRequestPath, validateOrcadManagedStopRequest } from './orcad-managed-stop-request' -import { claimOrcadManagedStopDecision } from './orcad-managed-stop-decision' export type OrcadStopRequestListener = { close(): void } @@ -117,10 +116,6 @@ export function installOrcadStopRequestListeners( orcadManagedStopRequestPath(managedStop.instance), (path) => { const request = validateOrcadManagedStopRequest(managedStop, path) - // A cancelled request is never acted on; keep listening for the next transaction. - if (claimOrcadManagedStopDecision(request, 'dispatched') === 'canceled') { - throw new Error('orcad_managed_stop_canceled') - } close() // Preparation is best effort: whatever it reports, the stop proceeds. void prepare(request) diff --git a/src/main/ssh/orcad-activation-crash-recovery.test.ts b/src/main/ssh/orcad-activation-crash-recovery.test.ts index 8a61515f1a7a..ddb7fb39ce66 100644 --- a/src/main/ssh/orcad-activation-crash-recovery.test.ts +++ b/src/main/ssh/orcad-activation-crash-recovery.test.ts @@ -93,8 +93,7 @@ function expectExactlyTheRecordedSlot(): void { function expectNoSlotOrSnapshotRemoved(): void { const removals = host.commands.filter((command) => /\brm -r?f\b/u.test(command)) for (const command of removals) { - // A file inside a slot (a consumed stop request) may go; the slot directory never does. - expect(command).not.toMatch(/rm -r?f '[^']*\/orcad-\d[^'/]*\/?'/u) + expect(command).not.toMatch(/rm -r?f '[^']*\/orcad-\d/u) expect(command).not.toMatch(/rm -r?f '[^']*orcad-state-snapshots/u) } } diff --git a/src/main/ssh/orcad-activation-host-test-harness.ts b/src/main/ssh/orcad-activation-host-test-harness.ts index 2ce1353529d2..1ac0fd05edcc 100644 --- a/src/main/ssh/orcad-activation-host-test-harness.ts +++ b/src/main/ssh/orcad-activation-host-test-harness.ts @@ -30,9 +30,6 @@ export class FakeOrcadHost { mutations = 0 crashAt: number | null = null crashMode: CrashMode = 'before' - /** How the slot's managed-stop command behaves: orcad exits, or keeps serving. */ - managedStop: 'exits' | 'stays' | 'stays-dispatched' = 'exits' - readonly dispatched = new Set() static deployedOld(): FakeOrcadHost { const host = new FakeOrcadHost() @@ -114,15 +111,6 @@ export class FakeOrcadHost { this.commands.push(command) const version = /\/orcad-(\d+\.\d+\.\d+\+[0-9a-f]+)/u.exec(command)?.[1] ?? null const snapshot = /orcad-state-snapshots\/([A-Za-z0-9][A-Za-z0-9.+-]*)/u.exec(command)?.[1] - if (command.includes('__ORCAD_RECORD_PRESENT__') && command.includes('orcad.lock')) { - const owner = [...this.alive][0] - return owner - ? `__ORCAD_RECORD_PRESENT__\n${JSON.stringify(lockRecord(owner))}` - : '__ORCAD_RECORD_ABSENT__\n' - } - if (command.includes('-managed-stop ') && version) { - return this.runManagedStopCommand(command, version) - } if (command.includes('__ORCAD_RECORD_PRESENT__')) { const value = command.includes('transaction.json') ? this.journal : this.record return value === null ? '__ORCAD_RECORD_ABSENT__\n' : `__ORCAD_RECORD_PRESENT__\n${value}` @@ -207,42 +195,6 @@ export class FakeOrcadHost { } return '' } - - /** The slot's `--complete-managed-stop` / `--cancel-managed-stop`, as orcad answers them. */ - private runManagedStopCommand(command: string, version: string): string { - const request = JSON.parse(command.slice(command.lastIndexOf(" '{") + 2, -1)) - if (command.includes('--cancel-managed-stop')) { - const outcome = this.dispatched.has(request.transactionId) ? 'dispatched' : 'canceled' - return JSON.stringify({ ...request, kind: 'orcad_managed_stop_cancellation', outcome }) - } - const verdict = this.mutate(() => { - if (this.managedStop === 'exits') { - this.dispatched.add(request.transactionId) - this.alive.delete(version) - } else if (this.managedStop === 'stays-dispatched') { - this.dispatched.add(request.transactionId) - } - return this.alive.has(version) ? 'live' : 'exited' - }) - return JSON.stringify({ - ...request, - kind: 'orcad_managed_stop_completion', - verdict, - receiptPersisted: verdict === 'exited', - ...(verdict === 'exited' ? { retirement: 'retired' } : {}) - }) - } -} - -function lockRecord(version: string) { - return { - pid: 1, - startedAtMs: null, - identity: 'uid-1000', - version, - acquiredAt: new Date(0).toISOString(), - nonce: `nonce-${version}` - } } export function readyLine(version: string): string { @@ -262,7 +214,6 @@ export function readyLine(version: string): string { platform: 'linux', arch: 'x64', pid: 1, - stopRequests: 1, terminalDaemon: { state: 'live', ownsFreshSessions: true, diff --git a/src/main/ssh/orcad-activation-record.ts b/src/main/ssh/orcad-activation-record.ts index 08f83a6970bd..42b1e6703a86 100644 --- a/src/main/ssh/orcad-activation-record.ts +++ b/src/main/ssh/orcad-activation-record.ts @@ -154,19 +154,6 @@ export function withRolledBackVersion( } } -/** The record after decommissioning `active`: nothing serves; the stopped build stays pinned. */ -export function withDeactivatedVersion(record: OrcadActivationRecord): OrcadActivationRecord { - return { - schemaVersion: ORCAD_ACTIVATION_SCHEMA_VERSION, - active: null, - // Kept so GC leaves the slot whose daemon may still own terminals, and a redeploy can find it. - previous: record.active, - activatedAt: null, - // No version is active, so there is nothing a pre-activation snapshot could roll back to. - snapshot: null - } -} - /** * Version dirs GC must not remove, as directory names. * diff --git a/src/main/ssh/orcad-activation-recovery.ts b/src/main/ssh/orcad-activation-recovery.ts index 06cd63a22877..655da3b9fae0 100644 --- a/src/main/ssh/orcad-activation-recovery.ts +++ b/src/main/ssh/orcad-activation-recovery.ts @@ -1,5 +1,5 @@ /** - * Finishing or undoing an activation, rollback or decommission that a crash, a lost connection or an + * Finishing or undoing an activation or rollback that a crash, a lost connection or an * unverifiable failure left fenced. Either way the host ends serving exactly the slot its * activation record names, or the fence stays for an operator. */ @@ -19,7 +19,6 @@ import { } from './orcad-activation-lock' import { RemoteInstallLockBusyError } from './ssh-relay-install-lock' import { ensureOrcadSlotServing, resolveOrcadSlotIdentity } from './orcad-recovery-slot' -import { reconcileOrcadDecommission } from './orcad-decommission-recovery' import { recoverOrcadIncumbent, type OrcadIncumbentRecoveryOptions @@ -90,13 +89,6 @@ export async function reconcileOrcadTransaction( if (plan.action === 'refuse') { return { outcome: 'refused', verdict: 'unverifiable', code: plan.code, reason: plan.reason } } - if ( - plan.action === 'confirm-decommissioned' || - plan.action === 'resume-stop' || - plan.action === 'keep-serving' - ) { - return reconcileOrcadDecommission(options, plan) - } if (plan.action === 'finish-commit') { await writeOrcadActivationRecord(options, plan.record) } diff --git a/src/main/ssh/orcad-activation-transaction-schema.ts b/src/main/ssh/orcad-activation-transaction-schema.ts index 14c776b3a66d..461bcd9a8f0d 100644 --- a/src/main/ssh/orcad-activation-transaction-schema.ts +++ b/src/main/ssh/orcad-activation-transaction-schema.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { OrcadManagedStopRequestSchema } from '../../shared/orcad-stop-request' import { ORCAD_INSTALL_MODEL, remoteInstallDirName, @@ -59,27 +58,9 @@ export const OrcadActivationTransactionSchema = z targetVersion: RemoteVersionSchema, recordAfter: z.unknown(), rescue: SnapshotVerdictSchema - }), - z.object({ - ...OrcadActivationTransactionCommonFields, - operation: z.literal('decommission'), - phase: z.enum(['prepared', 'stop-dispatched', 'process-exited']), - activeVersion: RemoteVersionSchema, - recordAfter: z.unknown(), - /** The instance-bound stop request; durable before it can reach the host. */ - request: OrcadManagedStopRequestSchema.nullable() }) ]) .superRefine((transaction, context) => { - if (transaction.operation === 'decommission') { - if ((transaction.phase === 'prepared') !== (transaction.request === null)) { - context.addIssue({ code: 'custom', message: 'Stop request is inconsistent with phase' }) - } - if (transaction.request && transaction.request.transactionId !== transaction.transactionId) { - context.addIssue({ code: 'custom', message: 'Stop request names another transaction' }) - } - return - } const beforeVerdict = transaction.phase === 'prepared' || transaction.phase === 'incumbent-stopped' const verdict = transaction.operation === 'activate' ? transaction.snapshot : transaction.rescue diff --git a/src/main/ssh/orcad-activation-transaction.ts b/src/main/ssh/orcad-activation-transaction.ts index cc00c51f642f..a349a4fa8492 100644 --- a/src/main/ssh/orcad-activation-transaction.ts +++ b/src/main/ssh/orcad-activation-transaction.ts @@ -1,5 +1,5 @@ /** - * The host-side journal that makes an orcad activation, rollback or decommission crash-safe. + * The host-side journal that makes an orcad activation or rollback crash-safe. * * Written before the first mutation and kept until the host is proven to serve exactly one * slot again. The activation record stays the commit point: a journal whose `recordAfter` @@ -15,12 +15,6 @@ import { type ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION, OrcadActivationTransactionSchema } from './orcad-activation-transaction-schema' -import { - orcadDecommissionTransactionDefect, - planOrcadDecommissionRecovery, - type OrcadDecommissionRecoveryPlan, - type OrcadDecommissionTransaction -} from './orcad-decommission-transaction' export const ORCAD_ACTIVATION_TRANSACTION_FILENAME = 'transaction.json' export const ORCAD_ACTIVATION_TRANSACTION_DIRNAME = '.orcad-activation-transaction' @@ -59,10 +53,7 @@ export type OrcadRollbackTransaction = { rescue: OrcadSnapshotVerdict } -export type OrcadActivationTransaction = - | OrcadActivateTransaction - | OrcadRollbackTransaction - | OrcadDecommissionTransaction +export type OrcadActivationTransaction = OrcadActivateTransaction | OrcadRollbackTransaction export type OrcadActivationTransactionReadResult = | { state: 'absent' } @@ -81,7 +72,6 @@ export type OrcadTransactionRecoveryPlan = activeVersion: string | null restoreState: OrcadSnapshotVerdict | null } - | OrcadDecommissionRecoveryPlan | { action: 'refuse'; code: string; reason: string } export function parseOrcadActivationTransaction( @@ -106,19 +96,6 @@ export function parseOrcadActivationTransaction( if (recordBefore.state === 'unreadable') { return recordBefore } - if (parsed.data.operation === 'decommission') { - const after = parseNestedRecord(parsed.data.recordAfter, 'recordAfter') - if (after.state === 'unreadable') { - return after - } - const transaction = { - ...parsed.data, - recordBefore: recordBefore.record, - recordAfter: after.record - } - const defect = orcadDecommissionTransactionDefect(transaction) - return defect ? unreadable(defect) : { state: 'ok', transaction } - } if (parsed.data.operation === 'activate') { const recordAfter = parsed.data.recordAfter === null @@ -176,12 +153,6 @@ export function planOrcadTransactionRecovery( transaction: OrcadActivationTransaction, currentRecord: OrcadActivationRecord ): OrcadTransactionRecoveryPlan { - if (transaction.operation === 'decommission') { - const committed = sameOrcadActivationRecord(currentRecord, transaction.recordAfter) - return committed || sameOrcadActivationRecord(currentRecord, transaction.recordBefore) - ? planOrcadDecommissionRecovery(transaction, committed) - : recordChangedRefusal(transaction) - } if ( transaction.recordAfter && sameOrcadActivationRecord(currentRecord, transaction.recordAfter) @@ -189,7 +160,13 @@ export function planOrcadTransactionRecovery( return { action: 'stabilize-committed', activeVersion: transaction.recordAfter.active } } if (!sameOrcadActivationRecord(currentRecord, transaction.recordBefore)) { - return recordChangedRefusal(transaction) + return { + action: 'refuse', + code: 'orcad_recovery_activation_record_changed', + reason: + `The activation record matches neither side of the interrupted ${transaction.operation}. ` + + 'Preserving the activation fence for operator inspection.' + } } if (transaction.phase === 'candidate-ready' || transaction.phase === 'target-ready') { return { action: 'finish-commit', record: transaction.recordAfter ?? neverRecord() } @@ -235,18 +212,6 @@ function parseNestedRecord( ) } -function recordChangedRefusal( - transaction: OrcadActivationTransaction -): Extract { - return { - action: 'refuse', - code: 'orcad_recovery_activation_record_changed', - reason: - `The activation record matches neither side of the interrupted ${transaction.operation}. ` + - 'Preserving the activation fence for operator inspection.' - } -} - function neverRecord(): never { throw new Error('A committed phase must carry its record; the schema enforces this.') } diff --git a/src/main/ssh/orcad-decommission-recovery.ts b/src/main/ssh/orcad-decommission-recovery.ts deleted file mode 100644 index 4c1fecbf698c..000000000000 --- a/src/main/ssh/orcad-decommission-recovery.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** Recovering an interrupted decommission from its journal entry; see orcad-decommission-transaction. */ -import { writeOrcadActivationRecord } from './orcad-activation-record-store' -import type { OrcadActivationRecoveryResult } from './orcad-activation-recovery' -import type { OrcadDecommissionRecoveryPlan } from './orcad-decommission-transaction' -import { settleOrcadDecommissionStop } from './orcad-decommission-stop' -import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch' -import { execOrcadRemote } from './orcad-remote-runtime-control' -import { - ensureOrcadSlotServing, - orcadSlotDir, - resolveOrcadSlotIdentity, - type OrcadSlotOptions -} from './orcad-recovery-slot' - -export async function reconcileOrcadDecommission( - options: OrcadSlotOptions, - plan: OrcadDecommissionRecoveryPlan -): Promise { - if (plan.action === 'keep-serving') { - // Nothing was sent, so the recorded version must still be the one serving. - const identity = await resolveOrcadSlotIdentity(options, plan.version) - return { - outcome: 'recovered', - resolution: 'restored-incumbent', - activeVersion: plan.version, - readiness: await ensureOrcadSlotServing(options, identity) - } - } - if (plan.action === 'resume-stop') { - const settlement = await settleOrcadDecommissionStop(options, plan.request) - if (settlement.state === 'unsettled') { - return { - outcome: 'refused', - verdict: settlement.verdict, - code: 'orcad_recovery_decommission_unsettled', - reason: settlement.reason - } - } - if (settlement.state === 'withdrawn') { - return reconcileOrcadDecommission(options, { - action: 'keep-serving', - version: plan.request.version - }) - } - return reconcileOrcadDecommission(options, { - action: 'confirm-decommissioned', - version: plan.request.version, - record: plan.record - }) - } - // Only proven exit commits; a slot that is live or unanswering keeps the fence. - const liveness = parseOrcadLiveness( - await execOrcadRemote( - options, - orcadLivenessProbeCommand(options.host, orcadSlotDir(options, plan.version)) - ) - ) - if (liveness !== 'DEAD') { - return { - outcome: 'refused', - verdict: liveness === 'LIVE' ? 'live' : 'unverifiable', - code: 'orcad_recovery_decommissioned_slot_not_exited', - reason: `orcad ${plan.version} is recorded as stopped but is ${liveness.toLowerCase()}.` - } - } - if (plan.record) { - await writeOrcadActivationRecord(options, plan.record) - } - return { outcome: 'recovered', resolution: 'committed', activeVersion: null, readiness: null } -} diff --git a/src/main/ssh/orcad-decommission-stop.ts b/src/main/ssh/orcad-decommission-stop.ts deleted file mode 100644 index 42e2f0fa6549..000000000000 --- a/src/main/ssh/orcad-decommission-stop.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Settling a dispatched decommission stop: proven exit, a clean cancellation, or a fence. - * - * Used by the decommission and by its crash recovery, so both read the host the same way. A - * lost answer is `unverifiable`, never exit; cancelling is how a stop that did not finish is - * withdrawn, and only an orcad-confirmed cancellation lets the fence go. - */ -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { - cancelRemoteOrcadManagedStop, - completeRemoteOrcadManagedStop -} from './orcad-managed-remote-stop' -import type { OrcadSlotOptions } from './orcad-recovery-slot' -import type { - OrcadDaemonRetirementVerdict, - OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' - -export type OrcadDecommissionStopSettlement = - | { state: 'exited'; retirement: OrcadDaemonRetirementVerdict } - /** orcad never acted on the request and keeps serving; the verdict says why it was withdrawn. */ - | { state: 'withdrawn'; verdict: 'live' | 'unverifiable'; reason: string } - /** orcad began stopping, or the host could not say: the fence must stay. */ - | { state: 'unsettled'; verdict: 'live' | 'unverifiable'; reason: string } - -export async function settleOrcadDecommissionStop( - options: OrcadSlotOptions, - request: OrcadManagedStopRequest -): Promise { - let verdict: 'live' | 'unverifiable' - let reason: string - try { - const completion = await completeRemoteOrcadManagedStop(options, request) - if (completion.verdict === 'exited') { - // A completion without a recorded outcome proves exit but not retirement. - return { state: 'exited', retirement: completion.retirement ?? 'unverifiable' } - } - verdict = completion.verdict - reason = `orcad ${request.version} did not exit (${completion.verdict}).` - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - verdict = 'unverifiable' - reason = `The host gave no verifiable stop verdict: ${errorMessage(error)}` - } - try { - const cancellation = await cancelRemoteOrcadManagedStop(options, request) - if (cancellation.outcome === 'canceled') { - return { - state: 'withdrawn', - verdict, - reason: `${reason} The stop request was withdrawn; orcad keeps serving.` - } - } - return { - state: 'unsettled', - verdict: 'live', - reason: `${reason} orcad already acted on the request and is still stopping.` - } - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return { - state: 'unsettled', - verdict: 'unverifiable', - reason: `${reason} Withdrawing the request gave no verifiable answer: ${errorMessage(error)}` - } - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-decommission-transaction.test.ts b/src/main/ssh/orcad-decommission-transaction.test.ts deleted file mode 100644 index cb49f77a0d50..000000000000 --- a/src/main/ssh/orcad-decommission-transaction.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - parseOrcadActivationTransaction, - planOrcadTransactionRecovery, - serializeOrcadActivationTransaction -} from './orcad-activation-transaction' -import { - createOrcadDecommissionTransaction, - withOrcadDecommissionProcessExited, - withOrcadDecommissionStopDispatched -} from './orcad-decommission-transaction' -import { FakeOrcadHost, NEW } from './orcad-activation-host-test-harness' -import type { OrcadManagedStopRequest } from '../../shared/orcad-stop-request' - -const T = new Date('2026-02-02T00:00:00.000Z') -const ID = '7f1c2a7e-6c1b-4a8e-9f0e-0a1b2c3d4e5f' -const before = { ...FakeOrcadHost.newRecord(), active: NEW } -const prepared = createOrcadDecommissionTransaction({ - transactionId: ID, - recordBefore: before, - now: T -}) -const request: OrcadManagedStopRequest = { - schemaVersion: 1, - transactionId: ID, - version: NEW, - runtimeId: 'runtime-1', - instance: { pid: 42, startedAtMs: 5, nonce: 'nonce', lockPath: '/home/u/.orca/orcad.lock' }, - retireIdleDaemon: true -} -const dispatched = withOrcadDecommissionStopDispatched(prepared, request, T) -const exited = withOrcadDecommissionProcessExited(dispatched, T) - -describe('the decommission journal entry', () => { - it('deactivates the active version and keeps it as previous', () => { - expect(prepared.recordAfter).toMatchObject({ active: null, previous: NEW, snapshot: null }) - }) - - it('round-trips every phase', () => { - for (const transaction of [prepared, dispatched, exited]) { - expect( - parseOrcadActivationTransaction(serializeOrcadActivationTransaction(transaction)) - ).toEqual({ state: 'ok', transaction }) - } - }) - - it.each([ - ['a dispatched phase without its request', { ...dispatched, request: null }], - ['a request before dispatch', { ...prepared, request }], - [ - 'a request for another transaction', - { - ...dispatched, - request: { ...request, transactionId: '5a7e1f0c-3b2d-4e6f-9a8b-7c6d5e4f3a2b' } - } - ], - [ - 'a request for another version', - { ...dispatched, request: { ...request, version: '0.9.0+ff01' } } - ], - ['a recordAfter that still serves', { ...prepared, recordAfter: before }] - ])('reads %s as unreadable, keeping the fence', (_name, transaction) => { - expect(parseOrcadActivationTransaction(JSON.stringify(transaction))).toMatchObject({ - state: 'unreadable' - }) - }) - - it('plans recovery from what the host is known to have done', () => { - const after = prepared.recordAfter - expect(planOrcadTransactionRecovery(prepared, before)).toEqual({ - action: 'keep-serving', - version: NEW - }) - expect(planOrcadTransactionRecovery(dispatched, before)).toEqual({ - action: 'resume-stop', - request, - record: after - }) - expect(planOrcadTransactionRecovery(exited, before)).toEqual({ - action: 'confirm-decommissioned', - version: NEW, - record: after - }) - expect(planOrcadTransactionRecovery(dispatched, after)).toEqual({ - action: 'confirm-decommissioned', - version: NEW, - record: null - }) - expect(planOrcadTransactionRecovery(dispatched, { ...after, previous: null })).toMatchObject({ - action: 'refuse' - }) - }) -}) diff --git a/src/main/ssh/orcad-decommission-transaction.ts b/src/main/ssh/orcad-decommission-transaction.ts deleted file mode 100644 index 4852cb965fe0..000000000000 --- a/src/main/ssh/orcad-decommission-transaction.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * The decommission entry of the activation journal: stop the active orcad with an - * instance-bound request and record that nothing serves, or put it back. - * - * Phases: `prepared` (nothing sent), `stop-dispatched` (the request may have reached the - * host), `process-exited` (exit proven by a completed-stop receipt). Only the last may commit. - */ -import { - serializeOrcadActivationRecord, - withDeactivatedVersion, - type OrcadActivationRecord -} from './orcad-activation-record' -import { ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION } from './orcad-activation-transaction-schema' -import type { OrcadManagedStopRequest } from '../../shared/orcad-stop-request' - -export type OrcadDecommissionTransaction = { - schemaVersion: typeof ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION - transactionId: string - operation: 'decommission' - phase: 'prepared' | 'stop-dispatched' | 'process-exited' - startedAt: string - updatedAt: string - activeVersion: string - recordBefore: OrcadActivationRecord - recordAfter: OrcadActivationRecord - request: OrcadManagedStopRequest | null -} - -export type OrcadDecommissionRecoveryPlan = - /** Exit was proven; write the deactivated record (if it is not there yet) and confirm. */ - | { action: 'confirm-decommissioned'; version: string; record: OrcadActivationRecord | null } - /** The request may have reached orcad; its completion or cancellation decides. */ - | { action: 'resume-stop'; request: OrcadManagedStopRequest; record: OrcadActivationRecord } - /** Nothing was sent; the active version must still be serving. */ - | { action: 'keep-serving'; version: string } - -export function createOrcadDecommissionTransaction(options: { - transactionId: string - recordBefore: OrcadActivationRecord & { active: string } - now: Date -}): OrcadDecommissionTransaction { - const timestamp = options.now.toISOString() - return { - schemaVersion: ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION, - transactionId: options.transactionId, - operation: 'decommission', - phase: 'prepared', - startedAt: timestamp, - updatedAt: timestamp, - activeVersion: options.recordBefore.active, - recordBefore: options.recordBefore, - recordAfter: withDeactivatedVersion(options.recordBefore), - request: null - } -} - -export function withOrcadDecommissionStopDispatched( - transaction: OrcadDecommissionTransaction, - request: OrcadManagedStopRequest, - now: Date -): OrcadDecommissionTransaction { - return { ...transaction, phase: 'stop-dispatched', updatedAt: now.toISOString(), request } -} - -export function withOrcadDecommissionProcessExited( - transaction: OrcadDecommissionTransaction, - now: Date -): OrcadDecommissionTransaction { - return { ...transaction, phase: 'process-exited', updatedAt: now.toISOString() } -} - -/** A reason when the parsed journal is not a coherent decommission; otherwise `null`. */ -export function orcadDecommissionTransactionDefect( - transaction: OrcadDecommissionTransaction -): string | null { - if (transaction.recordBefore.active !== transaction.activeVersion) { - return 'decommission version does not match recordBefore' - } - if ( - serializeOrcadActivationRecord(transaction.recordAfter) !== - serializeOrcadActivationRecord(withDeactivatedVersion(transaction.recordBefore)) - ) { - return 'recordAfter is not the deactivated recordBefore' - } - if (transaction.request && transaction.request.version !== transaction.activeVersion) { - return 'stop request names another version' - } - return null -} - -/** Called once the current record matched one side of the transaction. */ -export function planOrcadDecommissionRecovery( - transaction: OrcadDecommissionTransaction, - committed: boolean -): OrcadDecommissionRecoveryPlan { - if (committed) { - return { action: 'confirm-decommissioned', version: transaction.activeVersion, record: null } - } - if (transaction.phase === 'process-exited') { - return { - action: 'confirm-decommissioned', - version: transaction.activeVersion, - record: transaction.recordAfter - } - } - if (transaction.phase === 'stop-dispatched' && transaction.request) { - return { - action: 'resume-stop', - request: transaction.request, - record: transaction.recordAfter - } - } - return { action: 'keep-serving', version: transaction.activeVersion } -} diff --git a/src/main/ssh/orcad-managed-remote-stop.ts b/src/main/ssh/orcad-managed-remote-stop.ts deleted file mode 100644 index 1200192d9dbc..000000000000 --- a/src/main/ssh/orcad-managed-remote-stop.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Stopping one remote orcad instance through its instance-bound request, never a signal. - * - * The client names the instance from two host records that must agree: the slot's readiness - * payload (runtime ID, PID, capability) and the data root's instance lock (PID, start time, - * nonce). The slot's own `orcad.js` then writes the request and proves exit on the host. - */ -import { shellEscape } from './ssh-connection-utils' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' -import { parseOrcadReadinessOutput, readOrcadReadinessCommand } from './orcad-remote-launch' -import { execOrcadRemote } from './orcad-remote-runtime-control' -import { readBoundedOrcadRemoteRecord } from './orcad-remote-record-file' -import { orcadSlotDir, type OrcadSlotOptions } from './orcad-recovery-slot' -import { joinRemotePath } from './ssh-remote-platform' -import { ORCAD_LOCK_FILE_NAME, parseOrcadInstanceLockRecord } from '../orcad/orcad-instance-lock' -import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, - ORCAD_COMPLETE_MANAGED_STOP_FLAG, - ORCAD_STOP_REQUESTS_CAPABILITY, - OrcadManagedStopCancellationSchema, - OrcadManagedStopCompletionSchema, - type OrcadManagedStopCancellation, - type OrcadManagedStopCompletion, - type OrcadManagedStopContext, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' - -const LOCK_RECORD_MAX_BYTES = 64 * 1024 - -export type OrcadManagedStopTarget = - | { state: 'ready'; context: OrcadManagedStopContext } - | { state: 'refused'; verdict: 'unverifiable'; code: string; reason: string } - -function refused(code: string, reason: string): OrcadManagedStopTarget { - return { state: 'refused', verdict: 'unverifiable', code, reason } -} - -/** Names the running instance of `version`, or says why the host could not prove which it is. */ -export async function readRemoteOrcadManagedStopTarget( - options: OrcadSlotOptions, - version: string -): Promise { - assertPosixOrcadHost(options.host) - const slotDir = orcadSlotDir(options, version) - const readiness = parseOrcadReadinessOutput( - await execOrcadRemote(options, readOrcadReadinessCommand(options.host, slotDir)) - ) - if (readiness.state !== 'ready' || !readiness.readiness.health) { - return refused( - 'orcad_managed_stop_readiness_unverifiable', - `orcad ${version} has no readable readiness record, so the running instance is unknown.` - ) - } - const { health, runtimeId } = readiness.readiness - if (health.stopRequests !== ORCAD_STOP_REQUESTS_CAPABILITY) { - return refused( - 'orcad_managed_stop_unsupported', - `orcad ${version} predates managed stop requests; it can only be stopped by signal.` - ) - } - const lockPath = joinRemotePath(options.host, options.userDataDir, ORCAD_LOCK_FILE_NAME) - const lockRead = await readBoundedOrcadRemoteRecord(options, lockPath, LOCK_RECORD_MAX_BYTES) - const lock = lockRead.state === 'present' ? parseOrcadInstanceLockRecord(lockRead.raw) : null - if (!lock || lock.pid !== health.pid || !runtimeId) { - return refused( - 'orcad_managed_stop_instance_unverifiable', - `The instance lock does not name the orcad ${version} that published readiness.` - ) - } - return { - state: 'ready', - context: { - version, - runtimeId, - instance: { pid: lock.pid, startedAtMs: lock.startedAtMs, nonce: lock.nonce, lockPath } - } - } -} - -export async function completeRemoteOrcadManagedStop( - options: OrcadSlotOptions, - request: OrcadManagedStopRequest -): Promise { - return OrcadManagedStopCompletionSchema.parse( - await runSlotCommand(options, request, ORCAD_COMPLETE_MANAGED_STOP_FLAG) - ) -} - -export async function cancelRemoteOrcadManagedStop( - options: OrcadSlotOptions, - request: OrcadManagedStopRequest -): Promise { - return OrcadManagedStopCancellationSchema.parse( - await runSlotCommand(options, request, ORCAD_CANCEL_MANAGED_STOP_FLAG) - ) -} - -/** Runs the slot's own build, which owns the request format it is asked to write. */ -async function runSlotCommand( - options: OrcadSlotOptions, - request: OrcadManagedStopRequest, - flag: string -): Promise { - const slotDir = orcadSlotDir(options, request.version) - const entry = joinRemotePath(options.host, slotDir, 'orcad.js') - const output = await execOrcadRemote( - options, - `${selectOrcadSlotRuntimeCommand(options.host, slotDir, options.nodePath)} && ` + - `"$orcad_runtime" ${shellEscape(entry)} ${flag} ${shellEscape(JSON.stringify(request))}` - ) - const line = output - .trim() - .split('\n') - .findLast((candidate) => candidate.trim().startsWith('{')) - if (!line) { - throw new Error('orcad managed stop command returned no verifiable answer') - } - const parsed: unknown = JSON.parse(line) - if (typeof parsed !== 'object' || parsed === null) { - throw new Error('orcad managed stop command returned no verifiable answer') - } - // The answer must be about this exact request, not another transaction's. - if (!('transactionId' in parsed) || parsed.transactionId !== request.transactionId) { - throw new Error('orcad managed stop command answered another transaction') - } - return parsed -} diff --git a/src/main/ssh/orcad-remote-decommission.test.ts b/src/main/ssh/orcad-remote-decommission.test.ts deleted file mode 100644 index 492fcd03e5e0..000000000000 --- a/src/main/ssh/orcad-remote-decommission.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as DeployHelpers from './ssh-relay-deploy-helpers' -import type * as RecordFile from './orcad-remote-record-file' -import type * as InstallLock from './ssh-relay-install-lock' - -vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ - ...(await importOriginal()), - execCommand: vi.fn() -})) -vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) -vi.mock('./ssh-relay-install-lock', async (importOriginal) => ({ - ...(await importOriginal()), - acquireInstallLock: vi.fn() -})) -vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ - ...(await importOriginal()), - writeAtomicOrcadRemoteRecord: vi.fn() -})) - -import { execCommand } from './ssh-relay-deploy-helpers' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' -import { decommissionRemoteOrcad } from './orcad-remote-stop' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { parseOrcadActivationRecord } from './orcad-activation-record' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' -import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' -import { FakeOrcadHost, OLD, type CrashMode } from './orcad-activation-host-test-harness' - -let host = new FakeOrcadHost() - -const slot = { - conn: {} as SshConnection, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/u', - nodePath: '/usr/bin/node', - userDataDir: '/home/u/.orca', - bindHost: '127.0.0.1', - port: 7777, - readinessTimeoutMs: 50, - sleep: async () => {}, - now: () => new Date('2026-02-02T00:00:00.000Z') -} -const idle: OrcadTerminalCensus = { - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 3 -} - -function currentRecord() { - const parsed = parseOrcadActivationRecord(host.record) - if (parsed.state !== 'ok') { - throw new Error('fixture record is unreadable') - } - return parsed.record -} - -const decommission = (census: OrcadTerminalCensus = idle) => - decommissionRemoteOrcad({ ...slot, record: currentRecord(), census }) - -beforeEach(() => { - vi.clearAllMocks() - host = FakeOrcadHost.deployedOld() - vi.mocked(execCommand).mockImplementation(async (_conn, command) => host.exec(command)) - vi.mocked(acquireInstallLock).mockImplementation(async () => host.acquireFence()) - vi.mocked(writeAtomicOrcadRemoteRecord).mockImplementation(async (_target, path, contents) => - host.write(path, contents) - ) -}) - -function expectExactlyTheRecordedSlot(): void { - const active = host.activeVersion() - expect([...host.alive]).toEqual(active ? [active] : []) - expect(host.journal).toBeNull() - expect(host.fence).toBe(false) -} - -describe('decommissioning a managed orcad', () => { - it('stops the active instance by request, then records that nothing serves', async () => { - expect(await decommission()).toEqual({ - outcome: 'decommissioned', - version: OLD, - retirement: 'retired' - }) - expect(currentRecord()).toMatchObject({ active: null, previous: OLD, snapshot: null }) - expectExactlyTheRecordedSlot() - // Never a signal: the instance-bound request is the only stop path. - expect(host.commands.some((command) => command.includes('kill -TERM'))).toBe(false) - }) - - it.each([ - [{ ...idle, liveSessions: null }, 'unverifiable', 'orcad_decommission_census_unavailable'], - [{ ...idle, liveSessions: 2 }, 'live', 'orcad_decommission_terminals_running'] - ] as const)('refuses while the census says %j', async (census, verdict, code) => { - expect(await decommission(census)).toMatchObject({ outcome: 'refused', verdict, code }) - expect(host.alive.has(OLD)).toBe(true) - expect(writeAtomicOrcadRemoteRecord).not.toHaveBeenCalled() - }) - - it('refuses an orcad that cannot be addressed by request', async () => { - host.alive.clear() - expect(await decommission()).toMatchObject({ - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_managed_stop_readiness_unverifiable' - }) - expect(writeAtomicOrcadRemoteRecord).not.toHaveBeenCalled() - }) - - it('withdraws a request orcad never acted on and keeps serving', async () => { - host.managedStop = 'stays' - expect(await decommission()).toMatchObject({ - outcome: 'refused', - verdict: 'live', - code: 'orcad_decommission_stop_withdrawn' - }) - expect(currentRecord().active).toBe(OLD) - expectExactlyTheRecordedSlot() - }) - - it('keeps the fence while orcad is still stopping, and recovery finishes once it exits', async () => { - host.managedStop = 'stays-dispatched' - expect(await decommission()).toMatchObject({ - outcome: 'refused', - verdict: 'live', - code: 'orcad_decommission_stop_unsettled' - }) - expect(host.fence).toBe(true) - expect(host.journal).not.toBeNull() - expect(await recoverInterruptedOrcadActivation(slot)).toMatchObject({ - outcome: 'refused', - code: 'orcad_recovery_decommission_unsettled' - }) - host.managedStop = 'exits' - expect(await recoverInterruptedOrcadActivation(slot)).toMatchObject({ - outcome: 'recovered', - resolution: 'committed', - activeVersion: null - }) - expectExactlyTheRecordedSlot() - }) - - it.each(['before', 'after'])( - 'recovers to exactly the recorded slot when interrupted at every mutation (%s)', - async (mode) => { - host = FakeOrcadHost.deployedOld() - await decommission() - const total = host.mutations - expect(total).toBeGreaterThan(3) - for (let crashAt = 1; crashAt <= total; crashAt += 1) { - host = FakeOrcadHost.deployedOld() - host.crashAt = crashAt - host.crashMode = mode - await decommission().catch(() => undefined) - host.crashAt = null - const result = await recoverInterruptedOrcadActivation(slot) - expect(['recovered', 'none'], `mutation ${crashAt}`).toContain(result.outcome) - expectExactlyTheRecordedSlot() - } - } - ) -}) diff --git a/src/main/ssh/orcad-remote-launch.ts b/src/main/ssh/orcad-remote-launch.ts index 6e7a6644ec03..9f13bbebbca0 100644 --- a/src/main/ssh/orcad-remote-launch.ts +++ b/src/main/ssh/orcad-remote-launch.ts @@ -21,7 +21,6 @@ import { } from './orcad-remote-host-support' import type { ServeReadiness } from '../server/serve-readiness' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' -import { ORCAD_STOP_REQUEST_FILENAME } from '../../shared/orcad-stop-request' /** Stdout of the launched candidate: exactly one `orca_server_ready` line, then nothing. */ export const ORCAD_READINESS_FILENAME = '.orcad-readiness' @@ -64,8 +63,6 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp // Why truncate: a re-launch into a dir that already holds a previous readiness line would // otherwise let the deploy activate on the OLD process's health payload. `: > ${readiness} &&`, - // A stop request the previous process never consumed must not stop this one. - `rm -f ${shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_STOP_REQUEST_FILENAME))} &&`, 'umask 077 &&', `ORCA_VERSION=${shellEscape(spec.fullVersion)}`, `ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`, diff --git a/src/main/ssh/orcad-remote-process-control.ts b/src/main/ssh/orcad-remote-process-control.ts index da4cf9687063..33fd877ba417 100644 --- a/src/main/ssh/orcad-remote-process-control.ts +++ b/src/main/ssh/orcad-remote-process-control.ts @@ -7,10 +7,6 @@ import { shellEscape } from './ssh-connection-utils' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' import { ORCAD_READINESS_FILENAME } from './orcad-remote-launch' -import { - ORCAD_STOP_REQUEST_FILENAME, - ORCAD_STOP_REQUESTS_CAPABILITY -} from '../../shared/orcad-stop-request' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' import { assertPosixOrcadHost as assertPosixHost, @@ -19,12 +15,10 @@ import { } from './orcad-remote-host-support' /** - * Ask the orcad recorded in a version dir to stop, and wait for it to go. + * Signal the orcad recorded in a version dir and wait for it to go. * - * A build whose readiness advertises `health.stopRequests` is asked through the slot-local - * request file, which only that orcad watches; older builds keep receiving SIGTERM. Both need - * the readiness PID to corroborate the launcher's PID first, so a reused PID is never stopped. * `justLaunched` is only for this client's fixed exec launcher, including pre-readiness exits. + * Incumbents need their own readiness PID to corroborate the launcher's PID before any signal. */ export function stopOrcadCommand( host: RemoteHostPlatform, @@ -37,34 +31,26 @@ export function stopOrcadCommand( assertPosixHost(host) const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) - const requestFile = shellEscape( - joinRemotePath(host, remoteInstallDir, ORCAD_STOP_REQUEST_FILENAME) - ) const readRuntimePid = [ `const r = JSON.parse(require('node:fs').readFileSync(process.argv[1], 'utf8'));`, `const pid = r?.type === 'orca_server_ready' ? r.health?.pid : null;`, `if (!Number.isSafeInteger(pid) || pid <= 1) process.exit(1);`, - `const mode = r.health?.stopRequests === ${ORCAD_STOP_REQUESTS_CAPABILITY} ? 'request' : 'signal';`, - `process.stdout.write(String(pid) + ':' + mode);` + `process.stdout.write(String(pid));` ].join(' ') return [ posixProcessAliveShellFunction({ refuseUnverifiable: true }), `pid=$(cat ${pidFile} 2>/dev/null);`, 'case "$pid" in "" | *[!0-9]* ) echo NO_PID; exit 0;; esac;', - 'stop_mode=signal;', // Older launchers recorded a waiting shell, whose exit does not prove runtime exit. ...(options.justLaunched ? [] : [ - `runtime_answer=$(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, options.nodePath)}; ` + + `runtime_pid=$(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, options.nodePath)}; ` + `"$orcad_runtime" -e ${shellEscape(readRuntimePid)} ${readiness} 2>/dev/null) || { echo UNKNOWN; exit 0; };`, - '[ "$pid" = "${runtime_answer%%:*}" ] || { echo UNKNOWN; exit 0; };', - 'stop_mode=${runtime_answer#*:};' + '[ "$pid" = "$runtime_pid" ] || { echo UNKNOWN; exit 0; };' ]), 'orcad_alive "$pid" || { echo ALREADY_EXITED; exit 0; };', - 'if [ "$stop_mode" = request ]; then', - `( umask 077; : > ${requestFile} ) 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };`, - 'else kill -TERM "$pid" 2>/dev/null || { echo SIGNAL_FAILED; exit 0; }; fi;', + 'kill -TERM "$pid" 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };', `i=0; while [ "$i" -lt ${options.waitSeconds} ]; do`, 'orcad_alive "$pid" || { echo STOPPED; exit 0; };', 'sleep 1; i=$((i + 1)); done;', diff --git a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts index 0b0cab2a7a8d..4839fd7b2c21 100644 --- a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts +++ b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts @@ -49,7 +49,6 @@ import { restoreOrcadStateSnapshotCommand } from './orcad-state-snapshot' import { getRemoteHostPlatform } from './ssh-remote-platform' -import { ORCAD_STOP_REQUEST_FILENAME } from '../../shared/orcad-stop-request' const host = getRemoteHostPlatform('linux-x64') let root = '' @@ -87,39 +86,20 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }) }) -async function launchTestRuntime( - legacyWrapper = false, - stopRequests = false -): Promise<{ +async function launchTestRuntime(legacyWrapper = false): Promise<{ runtimePid: number recordedPid: number terminatedFile: string }> { const terminatedFile = join(versionDir, 'terminated') - const requestFile = join(versionDir, ORCAD_STOP_REQUEST_FILENAME) writeFileSync( join(versionDir, 'orcad.js'), [ - `const fs = require('node:fs');`, `process.on('SIGTERM', () => {`, - ` fs.writeFileSync(${JSON.stringify(terminatedFile)}, 'terminated');`, + ` require('node:fs').writeFileSync(${JSON.stringify(terminatedFile)}, 'terminated');`, ` process.exit(0);`, `});`, - ...(stopRequests - ? [ - // Like orcad's listener: consume the slot request, then stop. - `setInterval(() => {`, - ` if (fs.existsSync(${JSON.stringify(requestFile)})) {`, - ` fs.unlinkSync(${JSON.stringify(requestFile)});`, - ` fs.writeFileSync(${JSON.stringify(terminatedFile)}, 'requested');`, - ` process.exit(0);`, - ` }`, - `}, 20);` - ] - : []), - `console.log(JSON.stringify({type: 'orca_server_ready', health: {pid: process.pid${ - stopRequests ? ', stopRequests: 1' : '' - }}}));`, + `console.log(JSON.stringify({type: 'orca_server_ready', health: {pid: process.pid}}));`, `setTimeout(() => process.exit(1), 10_000);` ].join('\n') ) @@ -358,21 +338,6 @@ describe('liveness and stop commands, run for real', () => { expect(stopTestRuntime()).toBe('already-exited') }) - it('stops a build that consumes stop requests by request file, not by signal', async () => { - const { terminatedFile } = await launchTestRuntime(false, true) - expect(stopTestRuntime()).toBe('stopped') - expect(readFileSync(terminatedFile, 'utf8')).toBe('requested') - expect(existsSync(join(versionDir, ORCAD_STOP_REQUEST_FILENAME))).toBe(false) - }) - - it('clears a stop request the previous process never consumed before launching', async () => { - writeFileSync(join(versionDir, ORCAD_STOP_REQUEST_FILENAME), '') - const { runtimePid } = await launchTestRuntime(false, true) - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('LIVE') - expect(runtimePid).toBeGreaterThan(1) - }) - it('refuses a legacy wrapper PID both before and after its shell exits', async () => { const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime(true) expect(recordedPid).not.toBe(runtimePid) diff --git a/src/main/ssh/orcad-remote-stop.ts b/src/main/ssh/orcad-remote-stop.ts deleted file mode 100644 index dcfbe314314b..000000000000 --- a/src/main/ssh/orcad-remote-stop.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Decommissioning a managed orcad: stop its active instance and record that nothing serves. - * - * Runs under the activation fence and journal, so an interrupted decommission recovers like an - * activation does. It refuses while the terminal census says terminals are live or cannot be - * counted, and it never signals: the instance-bound request reaches only the orcad it names. - * Only proven exit deactivates the record; anything less withdraws the request or keeps the - * fence. POSIX hosts only, like the rest of the orcad deploy core. - */ -import { randomUUID } from 'node:crypto' -import type { OrcadActivationRecord } from './orcad-activation-record' -import { - readOrcadActivationRecord, - writeOrcadActivationRecord -} from './orcad-activation-record-store' -import { sameOrcadActivationRecord } from './orcad-activation-transaction' -import { - readOrcadActivationTransaction, - writeOrcadActivationTransaction -} from './orcad-activation-transaction-store' -import { withOrcadActivationLock } from './orcad-activation-lock' -import { - createOrcadDecommissionTransaction, - withOrcadDecommissionProcessExited, - withOrcadDecommissionStopDispatched -} from './orcad-decommission-transaction' -import { readRemoteOrcadManagedStopTarget } from './orcad-managed-remote-stop' -import { settleOrcadDecommissionStop } from './orcad-decommission-stop' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import type { OrcadSlotOptions } from './orcad-recovery-slot' -import type { OrcadDecommissionResult } from '../../shared/orcad-decommission' -import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' - -export type OrcadDecommissionOptions = OrcadSlotOptions & { - /** The record the caller reviewed; a changed host record refuses rather than guessing. */ - record: OrcadActivationRecord - /** Taken by the caller just before; any live or uncounted terminal refuses. */ - census: OrcadTerminalCensus - now?: () => Date -} - -type Refusal = Extract - -function refuse(verdict: Refusal['verdict'], code: string, reason: string): Refusal { - return { outcome: 'refused', verdict, code, reason } -} - -function censusRefusal(census: OrcadTerminalCensus): Refusal | null { - if (census.liveSessions === null) { - return refuse( - 'unverifiable', - 'orcad_decommission_census_unavailable', - 'The terminal daemon did not answer a session count, so decommissioning could end ' + - 'running work. Retry when the host answers.' - ) - } - if (census.liveSessions > 0) { - return refuse( - 'live', - 'orcad_decommission_terminals_running', - `${census.liveSessions} terminal${census.liveSessions === 1 ? ' is' : 's are'} still ` + - 'running on this host. Close them before decommissioning the server.' - ) - } - return null -} - -export async function decommissionRemoteOrcad( - options: OrcadDecommissionOptions -): Promise { - assertPosixOrcadHost(options.host) - const now = options.now ?? ((): Date => new Date()) - return withOrcadActivationLock(options, async (lock) => { - if (await readOrcadActivationTransaction(options)) { - lock.retain() - return refuse( - 'unverifiable', - 'orcad_activation_recovery_required', - 'An earlier activation on this host was interrupted. Recover it before decommissioning.' - ) - } - const record = await readOrcadActivationRecord(options) - if (!sameOrcadActivationRecord(record, options.record)) { - return refuse( - 'unverifiable', - 'orcad_decommission_record_changed', - 'The host activation record changed since it was reviewed. Refresh and try again.' - ) - } - const activeVersion = record.active - if (!activeVersion) { - return refuse( - 'unverifiable', - 'orcad_decommission_nothing_active', - 'No orcad version is active on this host, so there is nothing to decommission.' - ) - } - const census = censusRefusal(options.census) - if (census) { - return census - } - const target = await readRemoteOrcadManagedStopTarget(options, activeVersion) - if (target.state === 'refused') { - return refuse(target.verdict, target.code, target.reason) - } - - let transaction = createOrcadDecommissionTransaction({ - transactionId: randomUUID(), - recordBefore: { ...record, active: activeVersion }, - now: now() - }) - await writeOrcadActivationTransaction(options, transaction) - lock.retainOnError() - const request = { - schemaVersion: 1 as const, - transactionId: transaction.transactionId, - ...target.context, - // Best effort: an idle daemon goes with orcad, a busy one keeps its terminals. - retireIdleDaemon: true as const - } - // Durable before it can reach the host, so recovery can settle exactly this request. - transaction = withOrcadDecommissionStopDispatched(transaction, request, now()) - await writeOrcadActivationTransaction(options, transaction) - - const settlement = await settleOrcadDecommissionStop(options, request) - if (settlement.state === 'withdrawn') { - // orcad never acted on it and keeps serving; the record never changed. - return refuse(settlement.verdict, 'orcad_decommission_stop_withdrawn', settlement.reason) - } - if (settlement.state === 'unsettled') { - lock.retain() - return refuse(settlement.verdict, 'orcad_decommission_stop_unsettled', settlement.reason) - } - transaction = withOrcadDecommissionProcessExited(transaction, now()) - await writeOrcadActivationTransaction(options, transaction) - await writeOrcadActivationRecord(options, transaction.recordAfter) - return { - outcome: 'decommissioned', - version: activeVersion, - retirement: settlement.retirement - } - }) -} diff --git a/src/main/ssh/orcad-update-plan.ts b/src/main/ssh/orcad-update-plan.ts index 97c94acfdb1e..e6c6e865d84f 100644 --- a/src/main/ssh/orcad-update-plan.ts +++ b/src/main/ssh/orcad-update-plan.ts @@ -19,13 +19,26 @@ * pre-activation snapshot rather than against a version comparison. */ import type { OrcadActivationRecord } from './orcad-activation-record' -import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' import { assessOrcadLiveDaemonCrossing, type OrcadDaemonProtocolFacts } from './orcad-daemon-protocol-crossing' -export type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' +export type OrcadTerminalCensus = { + /** + * Sessions the live daemon owns right now. `null` means the probe could not answer — + * never treated as zero, because loss of contact is not evidence of process death + * (docs/reference/ssh-execution-boundary.md). + */ + liveSessions: number | null + /** + * Of those, how many started at or after `record.activatedAt`. These are the sessions the + * pre-activation snapshot does not describe. + */ + startedSinceActivation: number | null + /** Protocol of the daemon that owns those sessions; `null` when it did not answer. */ + daemonProtocolVersion: number | null +} export type OrcadUpdateDecision = | { action: 'noop'; reason: string } diff --git a/src/shared/orcad-decommission.ts b/src/shared/orcad-decommission.ts deleted file mode 100644 index 9d1c9c3f789a..000000000000 --- a/src/shared/orcad-decommission.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** What a client learns from decommissioning a managed orcad. */ -import { z } from 'zod' - -export const OrcadDecommissionResultSchema = z.discriminatedUnion('outcome', [ - z.object({ - outcome: z.literal('decommissioned'), - version: z.string().min(1).max(255), - /** The daemon's fate: `retired`, or kept with its terminals (`live`/`unverifiable`). */ - retirement: z.enum(['retired', 'live', 'unverifiable']) - }), - z.object({ - outcome: z.literal('refused'), - verdict: z.enum(['live', 'unverifiable']), - code: z.string(), - reason: z.string() - }) -]) - -export type OrcadDecommissionResult = z.infer diff --git a/src/shared/orcad-stop-request.ts b/src/shared/orcad-stop-request.ts index 9b3d89aba2be..ee3bfaf474c6 100644 --- a/src/shared/orcad-stop-request.ts +++ b/src/shared/orcad-stop-request.ts @@ -44,14 +44,10 @@ export type OrcadManagedStopContext = Omit< /** The execution host's verdict; `exited` only on proof, never on silence. */ export const OrcadManagedStopVerdictSchema = z.enum(['live', 'unverifiable', 'exited']) -// Outputs are read by clients that may be older than the host: unknown fields are tolerated. -export const OrcadManagedStopCompletionSchema = z.object({ - ...OrcadManagedStopRequestSchema.shape, +export const OrcadManagedStopCompletionSchema = OrcadManagedStopRequestSchema.extend({ kind: z.literal('orcad_managed_stop_completion'), verdict: OrcadManagedStopVerdictSchema, - receiptPersisted: z.boolean(), - /** For a request that asked to retire the daemon, the outcome its receipt recorded. */ - retirement: z.enum(['retired', 'live', 'unverifiable']).optional() + receiptPersisted: z.boolean() }) /** `retired` only when the daemon accepted; a busy daemon stays up and keeps its terminals. */ @@ -81,28 +77,3 @@ export type OrcadManagedStopCompletion = z.infer export type OrcadDaemonRetirementVerdict = z.infer export type OrcadDaemonRetirementRecord = z.infer - -export const ORCAD_CANCEL_MANAGED_STOP_FLAG = '--cancel-managed-stop' -/** Readiness `health.stopRequests`: this build consumes stop files and both commands above. */ -export const ORCAD_STOP_REQUESTS_CAPABILITY = 1 - -/** - * Which side won a managed request: the running orcad acting on it, or a client cancelling it. - * Created exclusively once per transaction, so the two can never both believe they won. - */ -export const OrcadManagedStopDecisionSchema = z.strictObject({ - schemaVersion: z.literal(1), - kind: z.literal('orcad_managed_stop_decision'), - request: OrcadManagedStopRequestSchema, - decision: z.enum(['dispatched', 'canceled']) -}) - -/** `dispatched` means orcad already acted on the request; the caller must await its exit. */ -export const OrcadManagedStopCancellationSchema = z.object({ - ...OrcadManagedStopRequestSchema.shape, - kind: z.literal('orcad_managed_stop_cancellation'), - outcome: z.enum(['canceled', 'dispatched']) -}) - -export type OrcadManagedStopDecision = z.infer -export type OrcadManagedStopCancellation = z.infer diff --git a/src/shared/orcad-terminal-census.ts b/src/shared/orcad-terminal-census.ts deleted file mode 100644 index c6a9bf23bee9..000000000000 --- a/src/shared/orcad-terminal-census.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * How many terminals a host's daemon runs, as a client hands it to deploy, rollback and - * decommission planning. `null` means the daemon could not answer, which is never zero: - * loss of contact is not evidence of process death (docs/reference/ssh-execution-boundary.md). - */ -import { z } from 'zod' - -const CountSchema = z.number().int().nonnegative().nullable() - -export const OrcadTerminalCensusSchema = z.object({ - /** Sessions the live daemon owns right now. */ - liveSessions: CountSchema, - /** Of those, how many started at or after the active version's activation. */ - startedSinceActivation: CountSchema, - /** Protocol of the daemon that owns those sessions. */ - daemonProtocolVersion: z.number().int().positive().nullable() -}) - -export type OrcadTerminalCensus = z.infer From 428da5729d456efc8f24568ca193e1cbb1b423bf Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:02 -0700 Subject: [PATCH 06/26] Revert "feat(orcad): supervisable server: stop requests, managed stop receipts and a lifetime that keeps its lock on failed teardown (#16741 T6-3) (#24433)" This reverts commit b093d3ab201d8700dc173c2f418f74c8758b61b6. --- docs/reference/orcad-operations.md | 26 -- src/main/daemon/daemon-init.ts | 1 - src/main/daemon/daemon-provider-state.ts | 7 - .../daemon/daemon-pty-event-subscriptions.ts | 7 - .../daemon/daemon-pty-router-test-fixture.ts | 1 - src/main/daemon/daemon-pty-router.ts | 4 - .../daemon/daemon-router-retirement.test.ts | 15 -- src/main/daemon/daemon-router-retirement.ts | 13 - .../parcel-watcher-process-dispose.test.ts | 48 ---- src/main/ipc/parcel-watcher-process.ts | 14 - .../electron-serve-browser-process.test.ts | 36 --- .../orcad/electron-serve-browser-process.ts | 8 +- .../external-chromium-browser-process.test.ts | 46 ---- .../external-chromium-browser-process.ts | 6 +- .../external-chromium-browser-session.test.ts | 59 +---- .../external-chromium-browser-session.ts | 33 +-- src/main/orcad/main-preflight-order.test.ts | 20 -- src/main/orcad/main.ts | 54 ++-- src/main/orcad/orcad-browser-provider.ts | 59 +---- ...orcad-browser-startup-cancellation.test.ts | 62 ----- src/main/orcad/orcad-browser-startup.test.ts | 103 -------- src/main/orcad/orcad-browser-startup.ts | 95 ------- .../orcad/orcad-completed-stop-receipt.ts | 109 -------- .../orcad/orcad-daemon-retirement.test.ts | 72 ------ src/main/orcad/orcad-daemon-retirement.ts | 97 ------- src/main/orcad/orcad-entry.ts | 71 ++---- src/main/orcad/orcad-instance-lock.test.ts | 91 +------ src/main/orcad/orcad-instance-lock.ts | 105 +++----- src/main/orcad/orcad-lifecycle-host.test.ts | 71 ------ src/main/orcad/orcad-lifecycle.ts | 68 +++-- .../orcad/orcad-managed-stop-admission.ts | 30 --- src/main/orcad/orcad-managed-stop-command.ts | 56 ---- .../orcad/orcad-managed-stop-completion.ts | 124 --------- src/main/orcad/orcad-managed-stop-request.ts | 66 ----- src/main/orcad/orcad-managed-stop.test.ts | 241 ------------------ src/main/orcad/orcad-push-startup.test.ts | 66 ++--- src/main/orcad/orcad-runtime-lifetime.test.ts | 81 ------ src/main/orcad/orcad-runtime-lifetime.ts | 42 --- .../orcad/orcad-stop-request-listener.test.ts | 143 ----------- src/main/orcad/orcad-stop-request-listener.ts | 139 ---------- .../orca-runtime-automation-operations.ts | 4 - ...ca-runtime-headless-terminal-close.test.ts | 48 ---- ...runtime-stop-explicitly-closed-tab-ptys.ts | 34 +-- ...me-legacy-worker-recovery-lifetime.test.ts | 124 --------- ...acy-worker-terminal-recovery-controller.ts | 27 +- ...-legacy-worker-terminal-recovery-runner.ts | 8 +- .../headless-pty-hydration-ordering.test.ts | 7 +- src/shared/orcad-stop-request.ts | 79 ------ 48 files changed, 185 insertions(+), 2535 deletions(-) delete mode 100644 src/main/ipc/parcel-watcher-process-dispose.test.ts delete mode 100644 src/main/orcad/external-chromium-browser-process.test.ts delete mode 100644 src/main/orcad/orcad-browser-startup-cancellation.test.ts delete mode 100644 src/main/orcad/orcad-browser-startup.test.ts delete mode 100644 src/main/orcad/orcad-browser-startup.ts delete mode 100644 src/main/orcad/orcad-completed-stop-receipt.ts delete mode 100644 src/main/orcad/orcad-daemon-retirement.test.ts delete mode 100644 src/main/orcad/orcad-daemon-retirement.ts delete mode 100644 src/main/orcad/orcad-lifecycle-host.test.ts delete mode 100644 src/main/orcad/orcad-managed-stop-admission.ts delete mode 100644 src/main/orcad/orcad-managed-stop-command.ts delete mode 100644 src/main/orcad/orcad-managed-stop-completion.ts delete mode 100644 src/main/orcad/orcad-managed-stop-request.ts delete mode 100644 src/main/orcad/orcad-managed-stop.test.ts delete mode 100644 src/main/orcad/orcad-runtime-lifetime.test.ts delete mode 100644 src/main/orcad/orcad-runtime-lifetime.ts delete mode 100644 src/main/orcad/orcad-stop-request-listener.test.ts delete mode 100644 src/main/orcad/orcad-stop-request-listener.ts delete mode 100644 src/main/runtime/orca-runtime-headless-terminal-close.test.ts delete mode 100644 src/main/runtime/runtime-legacy-worker-recovery-lifetime.test.ts delete mode 100644 src/shared/orcad-stop-request.ts diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md index c9f7f4fee361..adc18586c213 100644 --- a/docs/reference/orcad-operations.md +++ b/docs/reference/orcad-operations.md @@ -150,32 +150,6 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to exits with code 1 if teardown stalls. The bundled runtime also stops gracefully if its launcher's IPC channel closes. On POSIX, both the launcher and runtime ignore `SIGHUP`, so terminal hangups do not stop a headless host. Use `SIGTERM` or `SIGINT` to stop it. -- **Stop requests.** A file stops orcad the same way `SIGTERM` does, without a PID that may - since have been reused by another process: - - `.orcad-stop-request` beside `orcad.js` in the running slot. orcad deletes it and stops. - - An instance-bound request in the data root, named - `.orcad-managed-stop-request.`. orcad stops only when it - names this orcad's version, runtime ID, PID, start time and lock nonce, and while the - instance lock still holds that record. The file is kept as evidence. - - `orcad --complete-managed-stop ''` writes that request, waits for the - instance to exit, and prints one JSON line whose `verdict` is `live`, `unverifiable` or - `exited`. `exited` needs proof: no process with that PID, or a PID whose start time shows - it now belongs to another process. On `exited` it writes - `/orcad-stop-receipts/.json`. It exits 0 whenever it printed a - verdict, 64 for a malformed invocation, and 1 for a failure before any verdict, which is - never evidence of exit. - - A request with `retireIdleDaemon: true` asks orcad to retire the terminal daemon too. This - is best effort and never blocks or fails the stop: - - The daemon is retired only when it proves it owns no live session across every - generation. - - A busy daemon (`live`) or one whose state cannot be proven (`unverifiable`) stays up with - its terminals, and orcad reopens new-terminal admission before exiting. - - The completed-stop receipt records `retirement` as `retired`, `live` or `unverifiable`. - If orcad exits without recording an outcome, the receipt says `unverifiable`. -- **Instance lock.** `/orcad.lock` names the running orcad. A record that is - unreadable, malformed or over 64 KiB is never reclaimed: orcad exits 78 until an operator - removes it. A shutdown whose teardown failed keeps the lock until the process exits, so a - second orcad cannot start beside a writer that may still be running. - **Exit codes.** | Code | Meaning | Supervisor should | diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 6f0bd4eefc54..52e6b2654b0f 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -10,7 +10,6 @@ export { listLiveDaemonPtyIds, listLiveDaemonSessions, requestIdleDaemonRetirement, - releaseDaemonRetirementFence, readDaemonPidRecord, replaceDaemonProvider, shutdownDaemon, diff --git a/src/main/daemon/daemon-provider-state.ts b/src/main/daemon/daemon-provider-state.ts index f32b8a3c204e..d981e4f7774a 100644 --- a/src/main/daemon/daemon-provider-state.ts +++ b/src/main/daemon/daemon-provider-state.ts @@ -178,10 +178,3 @@ export async function requestIdleDaemonRetirement(): Promise sessions.map((sessionId) => ({ sessionId, isAlive: true }))), requestIdleRetirement: vi.fn(async () => ({ state: 'retiring' as const })), - releaseIdleRetirementFence: vi.fn(), hasPty: vi.fn((id: string) => sessions.includes(id)), probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)), write: vi.fn((id: string, data: string) => { diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 8b5b527cb31d..c5f242300f2e 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -70,10 +70,6 @@ export class DaemonPtyRouter implements IPtyProvider { return this.retirement.requestIdleRetirement() } - releaseIdleRetirementFence(): void { - this.retirement.releaseFence() - } - supportsGitCredentialGuardHost(sessionId?: string): boolean { const adapter = sessionId ? this.adapterFor(sessionId) : this.current return adapter.supportsGitCredentialGuardHost() diff --git a/src/main/daemon/daemon-router-retirement.test.ts b/src/main/daemon/daemon-router-retirement.test.ts index 66e6e60172e6..f47b4860ada0 100644 --- a/src/main/daemon/daemon-router-retirement.test.ts +++ b/src/main/daemon/daemon-router-retirement.test.ts @@ -47,18 +47,3 @@ it('keeps the fence through a lost native reply and failed retry inventory', asy await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) expect(retirement.admissionClosed).toBe(true) }) - -it('releases a fence left by an incomplete retirement, but never one that retired', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - vi.mocked(current.requestIdleRetirement).mockResolvedValueOnce({ state: 'busy', liveSessions: 0 }) - const retirement = new DaemonRouterRetirement(() => [current]) - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - expect(retirement.admissionClosed).toBe(true) - retirement.releaseFence() - expect(retirement.admissionClosed).toBe(false) - expect(current.releaseIdleRetirementFence).toHaveBeenCalledOnce() - - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'retiring' }) - retirement.releaseFence() - expect(retirement.admissionClosed).toBe(true) -}) diff --git a/src/main/daemon/daemon-router-retirement.ts b/src/main/daemon/daemon-router-retirement.ts index 3ea17ab581a7..19420b036a95 100644 --- a/src/main/daemon/daemon-router-retirement.ts +++ b/src/main/daemon/daemon-router-retirement.ts @@ -6,22 +6,10 @@ export class DaemonRouterRetirement { admissionClosed = false spawnInFlight = 0 private retirementAttempted = false - private retired = false private idleRetirementPromise: Promise | null = null constructor(private readonly allAdapters: () => DaemonPtyAdapter[]) {} - /** Reopens a fence left by an attempt that did not retire every generation. */ - releaseFence(): void { - if (this.idleRetirementPromise || this.retired) { - return - } - this.admissionClosed = false - for (const adapter of this.allAdapters()) { - adapter.releaseIdleRetirementFence() - } - } - async requestIdleRetirement(): Promise { if (this.idleRetirementPromise) { return this.idleRetirementPromise @@ -72,7 +60,6 @@ export class DaemonRouterRetirement { this.retirementAttempted = true const results = await Promise.all(adapters.map((adapter) => adapter.requestIdleRetirement())) if (results.every((result) => result.state === 'retiring')) { - this.retired = true return { state: 'retiring' } } const refusedLiveSessions = results.reduce( diff --git a/src/main/ipc/parcel-watcher-process-dispose.test.ts b/src/main/ipc/parcel-watcher-process-dispose.test.ts deleted file mode 100644 index fd022c16622f..000000000000 --- a/src/main/ipc/parcel-watcher-process-dispose.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -const { supervisorDispose, poolDispose } = vi.hoisted(() => ({ - supervisorDispose: vi.fn<() => Promise>(), - poolDispose: vi.fn<() => Promise>() -})) - -vi.mock('./parcel-watcher-process-supervisor', () => ({ - WatcherProcessSupervisor: class { - disposeAndWait = supervisorDispose - } -})) -vi.mock('./runtime-watcher-process-pool', () => ({ - RuntimeWatcherProcessPool: class { - disposeAndWait = poolDispose - } -})) - -import { disposeWatcherProcessAndWait } from './parcel-watcher-process' - -describe('disposeWatcherProcessAndWait', () => { - it('waits for both watcher hosts, and reports a failed one only after the other settles', async () => { - let finishPool!: () => void - supervisorDispose.mockRejectedValueOnce(new Error('child still running')) - poolDispose.mockReturnValueOnce( - new Promise((resolve) => { - finishPool = resolve - }) - ) - const settled = vi.fn() - const disposal = disposeWatcherProcessAndWait().catch((error: unknown) => { - settled() - throw error - }) - await Promise.resolve() - expect(settled).not.toHaveBeenCalled() - finishPool() - await expect(disposal).rejects.toMatchObject({ message: 'watcher_process_shutdown_incomplete' }) - }) - - it('resolves once every owned child has exited', async () => { - supervisorDispose.mockResolvedValueOnce() - poolDispose.mockResolvedValueOnce() - await expect(disposeWatcherProcessAndWait()).resolves.toBeUndefined() - expect(supervisorDispose).toHaveBeenCalled() - expect(poolDispose).toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/parcel-watcher-process.ts b/src/main/ipc/parcel-watcher-process.ts index 74e2c3ffa694..223fed564f81 100644 --- a/src/main/ipc/parcel-watcher-process.ts +++ b/src/main/ipc/parcel-watcher-process.ts @@ -62,20 +62,6 @@ export function disposeWatcherProcess(): void { } } -/** Dispose both watcher hosts and wait for every child they own to exit. */ -export async function disposeWatcherProcessAndWait(): Promise { - const results = await Promise.allSettled([ - sharedWatcherProcessSupervisor.disposeAndWait(), - runtimeWatcherProcessPool.disposeAndWait() - ]) - const failures = results.flatMap((result) => - result.status === 'rejected' ? [result.reason] : [] - ) - if (failures.length > 0) { - throw new AggregateError(failures, 'watcher_process_shutdown_incomplete') - } -} - export function resetWatcherProcessForTest(): void { sharedWatcherProcessSupervisor.resetForTest() } diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index 8ce23178f76b..4994003eef24 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -131,42 +131,6 @@ afterEach(async () => { }) describe('ElectronServeBrowserProcess start-up', () => { - it('does not launch when startup is already cancelled', async () => { - const processHandle = new ElectronServeBrowserProcess(INSTALLED_EXECUTABLE) - started.push(processHandle) - await expect(processHandle.start(AbortSignal.abort())).rejects.toThrow() - expect(spawnProcessMock).not.toHaveBeenCalled() - }) - - it('cancels readiness polling and cleans up the unready sidecar', async () => { - await setControl({ capabilities: [['runtime.v1']] }) - const controller = new AbortController() - const processHandle = new ElectronServeBrowserProcess(INSTALLED_EXECUTABLE) - started.push(processHandle) - const starting = processHandle.start(controller.signal) - const outcome = starting.then( - () => ({ rejected: false }), - () => ({ rejected: true }) - ) - try { - await vi.waitFor(async () => expect(await sidecarRequests()).not.toHaveLength(0), { - timeout: 10_000 - }) - } finally { - controller.abort() - await outcome - } - expect(await outcome).toEqual({ rejected: true }) - expect(processHandle.isAvailable()).toBe(false) - const userDataPath = (spawnSpec().args ?? []) - .find((arg) => arg.startsWith('--user-data-dir='))! - .slice('--user-data-dir='.length) - const metadata = JSON.parse(await readFile(join(userDataPath, 'orca-runtime.json'), 'utf8')) - await processHandle.stop() - expect(existsSync(userDataPath)).toBe(false) - expect(() => process.kill(metadata.pid, 0)).toThrow() - }) - it('launches the installed app in headless serve mode without orcad browser env', async () => { for (const key of AGENT_BROWSER_ENVIRONMENT_KEYS) { vi.stubEnv(key, `leaked-${key}`) diff --git a/src/main/orcad/electron-serve-browser-process.ts b/src/main/orcad/electron-serve-browser-process.ts index f03406e1eb0b..ae3508289c99 100644 --- a/src/main/orcad/electron-serve-browser-process.ts +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -97,13 +97,11 @@ export class ElectronServeBrowserProcess { constructor(private readonly executablePath: string) {} - async start(signal?: AbortSignal): Promise { - signal?.throwIfAborted() + async start(): Promise { const temporaryRoot = process.platform === 'win32' ? tmpdir() : '/tmp' const userDataPath = await mkdtemp(join(temporaryRoot, 'orcad-browser-')) this.sidecarDataPath = userDataPath const port = await reserveLoopbackPort() - signal?.throwIfAborted() const child = spawnProcess({ program: this.executablePath, args: [ @@ -124,14 +122,12 @@ export class ElectronServeBrowserProcess { const deadline = Date.now() + START_TIMEOUT_MS let lastError: unknown = null while (Date.now() < deadline) { - signal?.throwIfAborted() const metadata = readRuntimeMetadata(userDataPath) if (metadata) { try { const status = RuntimeStatusResult.parse( await sendOrcadSidecarRequest(metadata, 'status.get', undefined, 5_000) ) - signal?.throwIfAborted() if (status.capabilities?.includes('browser.headless.v1')) { this.metadata = metadata return @@ -144,7 +140,7 @@ export class ElectronServeBrowserProcess { if (child.exitCode !== null || child.signalCode !== null) { break } - await delay(100, undefined, { signal }) + await delay(100) } throw new Error( `Installed Electron browser provider did not become ready: ${ diff --git a/src/main/orcad/external-chromium-browser-process.test.ts b/src/main/orcad/external-chromium-browser-process.test.ts deleted file mode 100644 index 6122a4b2fe4b..000000000000 --- a/src/main/orcad/external-chromium-browser-process.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { start, stop, initialize, clear } = vi.hoisted(() => ({ - start: vi.fn(), - stop: vi.fn(), - initialize: vi.fn(), - clear: vi.fn() -})) -vi.mock('./external-chromium-browser-session', () => ({ - ExternalChromiumBrowserSession: class { - start = start - stop = stop - } -})) -vi.mock('./external-chromium-tab-registry', () => ({ - ExternalChromiumTabRegistry: class { - initialize = initialize - clear = clear - } -})) - -import { ExternalChromiumBrowserProcess } from './external-chromium-browser-process' - -describe('external Chromium startup cancellation', () => { - beforeEach(() => vi.resetAllMocks()) - - it('does not publish a session that resolves after cancellation and permits cleanup', async () => { - const controller = new AbortController() - start.mockImplementation(async () => { - controller.abort() - return 'tab-live' - }) - const browser = new ExternalChromiumBrowserProcess( - '/opt/orca/agent-browser', - { executablePath: '/opt/orca/chromium', provider: 'chromium' }, - '/state' - ) - await expect(browser.start(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }) - expect(start).toHaveBeenCalledWith(controller.signal) - expect(initialize).not.toHaveBeenCalled() - expect(browser.isAvailable()).toBe(false) - await browser.stop() - expect(stop).toHaveBeenCalledWith() - expect(clear).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/orcad/external-chromium-browser-process.ts b/src/main/orcad/external-chromium-browser-process.ts index 112278547cdb..912a44775e78 100644 --- a/src/main/orcad/external-chromium-browser-process.ts +++ b/src/main/orcad/external-chromium-browser-process.ts @@ -37,10 +37,8 @@ export class ExternalChromiumBrowserProcess { this.tabs = new ExternalChromiumTabRegistry(this.session) } - async start(signal?: AbortSignal): Promise { - const activeTabId = await this.session.start(signal) - signal?.throwIfAborted() - this.tabs.initialize(activeTabId) + async start(): Promise { + this.tabs.initialize(await this.session.start()) this.available = true } diff --git a/src/main/orcad/external-chromium-browser-session.test.ts b/src/main/orcad/external-chromium-browser-session.test.ts index 989d3f6f2170..730a3d36e2d8 100644 --- a/src/main/orcad/external-chromium-browser-session.test.ts +++ b/src/main/orcad/external-chromium-browser-session.test.ts @@ -1,9 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdir } from 'node:fs/promises' -const runProcessMock = vi.fn<(spec: Spec) => Promise>() +const runProcessMock = vi.fn() vi.mock('../../shared/child-process/run-process', () => ({ - runProcess: (spec: Spec) => runProcessMock(spec) + runProcess: (spec: unknown) => runProcessMock(spec) })) vi.mock('node:fs/promises', () => ({ mkdir: vi.fn(async () => undefined), @@ -22,7 +21,7 @@ const BASE = { sessionName: 'orca-orcad-0123456789abcdef' } -type Spec = { args?: readonly string[]; env?: NodeJS.ProcessEnv; signal?: AbortSignal } +type Spec = { args?: readonly string[]; env?: NodeJS.ProcessEnv } function commands(): string[][] { return runProcessMock.mock.calls.map((call) => [...((call[0] as Spec).args ?? [])]) @@ -31,60 +30,8 @@ function commands(): string[][] { describe('orcad external-chromium agent-browser environment', () => { beforeEach(() => { runProcessMock.mockReset() - vi.mocked(mkdir).mockClear() }) - it('does no work when startup is already aborted', async () => { - const controller = new AbortController() - controller.abort() - const session = new ExternalChromiumBrowserSession( - '/opt/orca/agent-browser', - { executablePath: BASE.executablePath, provider: 'chromium' }, - '/state' - ) - await expect(session.start(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }) - expect(mkdir).not.toHaveBeenCalled() - expect(runProcessMock).not.toHaveBeenCalled() - }) - - it.each(['tab', 'close', 'open'])( - 'does not continue startup after abort during %s', - async (phase) => { - const controller = new AbortController() - runProcessMock.mockImplementation(async (spec: Spec) => { - if (spec.args?.includes(phase)) { - controller.abort() - } - return { - code: 0, - signal: null, - stdout: JSON.stringify({ success: true, data: { tabs: [] } }), - stderr: '', - timedOut: false - } - }) - const session = new ExternalChromiumBrowserSession( - '/opt/orca/agent-browser', - { executablePath: BASE.executablePath, provider: 'chromium' }, - '/state' - ) - await expect(session.start(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }) - const specs = runProcessMock.mock.calls.map(([spec]) => spec) - const issued = specs.map((spec) => - spec.args?.find((arg) => ['tab', 'close', 'open'].includes(arg)) - ) - expect(issued).toEqual( - phase === 'tab' ? ['tab'] : phase === 'close' ? ['tab', 'close'] : ['tab', 'close', 'open'] - ) - for (const spec of specs) { - expect(spec.signal).toBe(spec.args?.includes('close') ? undefined : controller.signal) - } - await session.stop() - expect(runProcessMock.mock.lastCall?.[0]).toMatchObject({ signal: undefined }) - expect(commands().at(-1)).toContain('close') - } - ) - // Why: this daemon owns the user's remote Chromium, so an idle bound would close a live browser. it('never bounds the daemon that owns the Chromium tree', () => { const env = externalChromiumAgentBrowserEnvironment({ inheritedEnv: {}, ...BASE }) diff --git a/src/main/orcad/external-chromium-browser-session.ts b/src/main/orcad/external-chromium-browser-session.ts index 56c7bd47e783..de72ae8a0614 100644 --- a/src/main/orcad/external-chromium-browser-session.ts +++ b/src/main/orcad/external-chromium-browser-session.ts @@ -87,25 +87,20 @@ export class ExternalChromiumBrowserSession { this.profilePath = join(statePath, `browser-${launch.provider}`) } - async start(signal?: AbortSignal): Promise { - signal?.throwIfAborted() + async start(): Promise { await mkdir(this.profilePath, { recursive: true }) - signal?.throwIfAborted() // Why: the session name is stable across runs, so a daemon an earlier orcad left behind is // still driving the user's Chromium. Unlike the pane bridge this session never passes --cdp, // so nothing binds it to the old process — a surviving one is reusable as-is, and closing it // would take the remote user's browser and every tab with it (#16367). - const reusable = await this.readActiveTabId(signal) - signal?.throwIfAborted() + const reusable = await this.readActiveTabId() if (reusable) { return reusable } // Nothing answered, so anything under this name is wedged or half-dead; reclaim it. await this.stop() - signal?.throwIfAborted() - await this.run(['open', 'about:blank'], COMMAND_TIMEOUT_MS, signal) - const opened = await this.readActiveTabId(signal) - signal?.throwIfAborted() + await this.run(['open', 'about:blank']) + const opened = await this.readActiveTabId() if (!opened) { throw new BrowserError( BROWSER_UNAVAILABLE_ERROR_CODE, @@ -115,12 +110,11 @@ export class ExternalChromiumBrowserSession { return opened } - private async readActiveTabId(signal?: AbortSignal): Promise { + private async readActiveTabId(): Promise { try { - const tabs = await this.readTabs(signal) + const tabs = await this.readTabs() return (tabs.find((tab) => tab.active) ?? tabs[0])?.tabId ?? null } catch { - signal?.throwIfAborted() return null } } @@ -137,10 +131,8 @@ export class ExternalChromiumBrowserSession { await this.run(['tab', agentPageId]) } - async readTabs(signal?: AbortSignal): Promise { - return ( - AgentBrowserTabsResult.parse(await this.run(['tab'], COMMAND_TIMEOUT_MS, signal)).tabs ?? [] - ) + async readTabs(): Promise { + return AgentBrowserTabsResult.parse(await this.run(['tab'])).tabs ?? [] } async screenshot(params: Record, full: boolean): Promise { @@ -160,12 +152,7 @@ export class ExternalChromiumBrowserSession { return { data } } - async run( - command: readonly string[], - timeoutMs = COMMAND_TIMEOUT_MS, - signal?: AbortSignal - ): Promise { - signal?.throwIfAborted() + async run(command: readonly string[], timeoutMs = COMMAND_TIMEOUT_MS): Promise { const args = ['--session', this.sessionName, '--profile', this.profilePath] if (this.launch.browserArgs?.length) { args.push('--args', this.launch.browserArgs.join('\n')) @@ -183,10 +170,8 @@ export class ExternalChromiumBrowserSession { args, env, timeoutMs, - signal, maxOutputBytes: MAX_OUTPUT_BYTES }) - signal?.throwIfAborted() if (result.timedOut) { throw new BrowserError('browser_timeout', 'Browser command timed out.') } diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts index 0b477db1a7eb..8a7fc60407fa 100644 --- a/src/main/orcad/main-preflight-order.test.ts +++ b/src/main/orcad/main-preflight-order.test.ts @@ -3,7 +3,6 @@ import { ORCAD_PROFILE_PREFLIGHT_FLAG, ORCAD_STARTUP_PREFLIGHT_FLAG } from '../../shared/orcad-profile-preflight' -import { ORCAD_COMPLETE_MANAGED_STOP_FLAG } from '../../shared/orcad-stop-request' /** * The precondition is only worth anything if it runs first. A loader failure is not @@ -39,12 +38,6 @@ vi.mock('./orcad-native-preflight', () => ({ } })) -vi.mock('./orcad-managed-stop-command', () => ({ - runOrcadManagedStopCommandAndExit: async (argv: string[]) => { - order.push(`managed-stop:${argv.join(' ')}`) - } -})) - vi.mock('./orcad-entry', () => ({ main: async () => { order.push('main') @@ -74,17 +67,4 @@ describe('orcad entry', () => { expect(order).toEqual(['profile-admission', 'preflight', 'main']) }) - - it('completes a managed stop without preflights, the bundled handoff, or a runtime', async () => { - vi.spyOn(process, 'argv', 'get').mockReturnValue([ - 'runtime', - 'orcad.js', - ORCAD_COMPLETE_MANAGED_STOP_FLAG, - '{}' - ]) - await import('./main') - await vi.waitFor(() => expect(order).toHaveLength(1)) - - expect(order).toEqual([`managed-stop:${ORCAD_COMPLETE_MANAGED_STOP_FLAG} {}`]) - }) }) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index e91ee01e8fac..46f98853c023 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -8,7 +8,6 @@ import { } from '../../shared/orcad-profile-preflight' import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight' import { handoffToBundledOrcad } from './orcad-bundled-runtime' -import { ORCAD_COMPLETE_MANAGED_STOP_FLAG } from '../../shared/orcad-stop-request' // Why exit before the preflight: reaching this line means the whole module graph resolved // under plain Node, which is all the build guard needs to prove. Probing natives or @@ -31,39 +30,28 @@ function failStartup(error: unknown): void { process.exit(resolveOrcadExitCode(error)) } -// Why before the bundled handoff and preflights: completing a stop must not start a runtime. -if (process.argv[2] === ORCAD_COMPLETE_MANAGED_STOP_FLAG) { - void import('./orcad-managed-stop-command').then(({ runOrcadManagedStopCommandAndExit }) => - runOrcadManagedStopCommandAndExit(process.argv.slice(2)) - ) -} else { - startOrcadProcess() -} - -function startOrcadProcess(): void { - try { - if (!handoffToBundledOrcad()) { - const flag = process.argv[2] - if ( - (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) && - process.argv.length === 4 - ) { - void runOrcadProfilePreflight(process.argv[3], { - nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG +try { + if (!handoffToBundledOrcad()) { + const flag = process.argv[2] + if ( + (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) && + process.argv.length === 4 + ) { + void runOrcadProfilePreflight(process.argv[3], { + nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG + }) + // Why exit: the owner reads to EOF, so a lingering native handle must not hold the probe open. + .then(() => process.stdout.write('', () => process.exit(0))) + .catch(failStartup) + } else { + void preflightBundledOrcadStartup() + .then(() => { + runOrcadNativePreflight() + return main() }) - // Why exit: the owner reads to EOF, so a lingering native handle must not hold the probe open. - .then(() => process.stdout.write('', () => process.exit(0))) - .catch(failStartup) - } else { - void preflightBundledOrcadStartup() - .then(() => { - runOrcadNativePreflight() - return main() - }) - .catch(failStartup) - } + .catch(failStartup) } - } catch (error) { - failStartup(error) } +} catch (error) { + failStartup(error) } diff --git a/src/main/orcad/orcad-browser-provider.ts b/src/main/orcad/orcad-browser-provider.ts index a350ef8f9f97..fdb8bfad34ba 100644 --- a/src/main/orcad/orcad-browser-provider.ts +++ b/src/main/orcad/orcad-browser-provider.ts @@ -23,7 +23,6 @@ export type OrcadBrowserProvider = { export type OrcadBrowserProviderOptions = { userDataPath: string - signal?: AbortSignal environment?: NodeJS.ProcessEnv resolveInstalledElectronExecutable?: () => Promise resolveAgentBrowserBinary?: () => string | null @@ -31,20 +30,6 @@ export type OrcadBrowserProviderOptions = { type ExecutableProbe = 'ok' | 'missing' | 'not_executable' -class OrcadBrowserCleanupError extends AggregateError {} - -async function cleanupFailedProvider( - processHandle: { stop(): Promise }, - startupError: unknown -): Promise { - try { - await processHandle.stop() - } catch (cleanupError) { - throw new OrcadBrowserCleanupError([startupError, cleanupError], 'orcad_browser_cleanup_failed') - } - throw startupError -} - /** Splits the two failures apart: a wrong path and a forgotten chmod +x need different fixes. */ async function probeExecutable(path: string): Promise { const mode = process.platform === 'win32' ? constants.F_OK : constants.X_OK @@ -114,14 +99,14 @@ export async function resolveInstalledElectronExecutable(): Promise { const processHandle = new ExternalChromiumBrowserProcess(agentBrowserPath, launch, userDataPath) try { - await processHandle.start(signal) + await processHandle.start() } catch (error) { - return cleanupFailedProvider(processHandle, error) + await processHandle.stop() + throw error } return { kind: launch.provider, @@ -131,15 +116,13 @@ async function startProvider( } } -async function startElectronServeProvider( - executablePath: string, - signal?: AbortSignal -): Promise { +async function startElectronServeProvider(executablePath: string): Promise { const processHandle = new ElectronServeBrowserProcess(executablePath) try { - await processHandle.start(signal) + await processHandle.start() } catch (error) { - return cleanupFailedProvider(processHandle, error) + await processHandle.stop() + throw error } return { kind: 'electron', @@ -154,9 +137,6 @@ export async function resolveOrcadBrowserProvider( options: OrcadBrowserProviderOptions ): Promise { const environment = options.environment ?? process.env - if (options.signal?.aborted) { - return null - } await mkdir(options.userDataPath, { recursive: true, mode: 0o700 }) const declined = (cause: RuntimeBrowserUnavailableCause): null => { @@ -167,23 +147,14 @@ export async function resolveOrcadBrowserProvider( const installedElectronExecutable = await ( options.resolveInstalledElectronExecutable ?? resolveInstalledElectronExecutable )() - if (options.signal?.aborted) { - return null - } // Why held rather than reported now: Chromium may still resolve, and if it does not, its // own concrete fault is the more actionable one for an operator who set the env var. let electronFailure: RuntimeBrowserUnavailableCause | null = null if (installedElectronExecutable) { try { setRuntimeBrowserUnavailableCause(null) - return await startElectronServeProvider(installedElectronExecutable, options.signal) + return await startElectronServeProvider(installedElectronExecutable) } catch (error) { - if (error instanceof OrcadBrowserCleanupError) { - throw error - } - if (options.signal?.aborted) { - return null - } console.warn('[orcad] Installed Electron browser provider unavailable:', error) electronFailure = { reason: 'electron_start_failed', detail: errorDetail(error) } } @@ -203,9 +174,6 @@ export async function resolveOrcadBrowserProvider( } const probe = await probeExecutable(chromiumExecutable) - if (options.signal?.aborted) { - return null - } if (probe !== 'ok') { return declined({ reason: probe === 'missing' ? 'executable_not_found' : 'executable_not_executable', @@ -218,16 +186,9 @@ export async function resolveOrcadBrowserProvider( return await startProvider( agentBrowserPath, { executablePath: chromiumExecutable, provider: 'chromium' }, - options.userDataPath, - options.signal + options.userDataPath ) } catch (error) { - if (error instanceof OrcadBrowserCleanupError) { - throw error - } - if (options.signal?.aborted) { - return null - } console.warn('[orcad] External Chromium browser provider unavailable:', error) return declined({ reason: 'chromium_start_failed', detail: errorDetail(error) }) } diff --git a/src/main/orcad/orcad-browser-startup-cancellation.test.ts b/src/main/orcad/orcad-browser-startup-cancellation.test.ts deleted file mode 100644 index 9eca8d4e1da5..000000000000 --- a/src/main/orcad/orcad-browser-startup-cancellation.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' -import { resolveOrcadBrowserProvider } from './orcad-browser-provider' - -const { start, stop, chromiumStart } = vi.hoisted(() => ({ - start: vi.fn(), - stop: vi.fn(), - chromiumStart: vi.fn() -})) -vi.mock('node:fs/promises', () => ({ mkdir: vi.fn(), access: vi.fn() })) -vi.mock('./electron-serve-browser-process', () => ({ - ElectronServeBrowserProcess: class { - start = start - stop = stop - } -})) -vi.mock('./external-chromium-browser-process', () => ({ - ExternalChromiumBrowserProcess: class { - start = chromiumStart - } -})) -beforeEach(() => vi.resetAllMocks()) - -const options = { - userDataPath: '/fixture', - resolveInstalledElectronExecutable: async () => '/fixture/electron', - resolveAgentBrowserBinary: () => '/fixture/driver', - environment: { ORCA_BROWSER_EXECUTABLE: '/fixture/chromium' } -} - -it('does not launch a provider after pre-cancellation', async () => { - await expect( - resolveOrcadBrowserProvider({ ...options, signal: AbortSignal.abort() }) - ).resolves.toBeNull() - expect(start).not.toHaveBeenCalled() - expect(chromiumStart).not.toHaveBeenCalled() -}) - -it('cleans cancelled Electron startup without launching a fallback', async () => { - const controller = new AbortController() - start.mockImplementation(async () => { - controller.abort() - controller.signal.throwIfAborted() - }) - await expect( - resolveOrcadBrowserProvider({ ...options, signal: controller.signal }) - ).resolves.toBeNull() - expect(stop).toHaveBeenCalledOnce() - expect(chromiumStart).not.toHaveBeenCalled() -}) - -it('propagates failed cleanup even when startup was cancelled', async () => { - const controller = new AbortController() - start.mockImplementation(async () => { - controller.abort() - controller.signal.throwIfAborted() - }) - stop.mockRejectedValue(new Error('sidecar still owned')) - await expect( - resolveOrcadBrowserProvider({ ...options, signal: controller.signal }) - ).rejects.toThrow('orcad_browser_cleanup_failed') - expect(chromiumStart).not.toHaveBeenCalled() -}) diff --git a/src/main/orcad/orcad-browser-startup.test.ts b/src/main/orcad/orcad-browser-startup.test.ts deleted file mode 100644 index 9f2f5812ac73..000000000000 --- a/src/main/orcad/orcad-browser-startup.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { startOrcadBrowserProvider } from './orcad-browser-startup' -import { resolveOrcadBrowserProvider, type OrcadBrowserProvider } from './orcad-browser-provider' -import { - createRuntimeBrowserCommands, - runtimeBrowserCommandsFactoryIsHeadless, - runtimeBrowserUnavailableCause, - setRuntimeBrowserCommandsFactory, - setRuntimeBrowserUnavailableCause -} from '../runtime/runtime-browser-commands-factory' -import type { - RuntimeBrowserCommandHost, - RuntimeBrowserCommands -} from '../runtime/orca-runtime-browser' - -vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: vi.fn() })) -afterEach(() => { - vi.restoreAllMocks() - setRuntimeBrowserCommandsFactory(null) - setRuntimeBrowserUnavailableCause(null) -}) - -const host: RuntimeBrowserCommandHost = Object.create(null) -function delayedProvider() { - const pending = Promise.withResolvers() - vi.mocked(resolveOrcadBrowserProvider).mockReturnValueOnce(pending.promise) - const command = vi.fn(() => ({ tabs: [] })) - const provider: OrcadBrowserProvider = { - kind: 'electron', - factory: vi.fn((): RuntimeBrowserCommands => - Object.assign(Object.create(null), { browserTabList: command }) - ), - isAvailable: vi.fn(() => true), - stop: vi.fn(async () => {}) - } - const startup = startOrcadBrowserProvider({ userDataPath: '/private-fixture' }) - return { pending, provider, command, startup } -} - -it('returns before discovery and keeps already-created commands usable after readiness', async () => { - const { pending, provider, command, startup } = delayedProvider() - const commands = createRuntimeBrowserCommands(host) - expect(runtimeBrowserCommandsFactoryIsHeadless()).toBe(false) - expect(() => commands.browserTabList({})).toThrow(/unavailable/) - pending.resolve(provider) - await startup.ready - expect(runtimeBrowserCommandsFactoryIsHeadless()).toBe(true) - commands.browserTabList({}) - commands.browserTabList({}) - expect(provider.factory).toHaveBeenCalledTimes(1) - expect(command).toHaveBeenCalledTimes(2) - await startup.stop() - expect(() => commands.browserTabList({})).toThrow(/unavailable/) - expect(runtimeBrowserCommandsFactoryIsHeadless()).toBe(false) -}) - -it('aborts pending discovery and awaits a late provider cleanup exactly once', async () => { - const { pending, provider, startup } = delayedProvider() - await Promise.resolve() - const signal = vi.mocked(resolveOrcadBrowserProvider).mock.calls.at(-1)![0].signal! - const stopped = startup.stop() - expect(startup.stop()).toBe(stopped) - expect(signal.aborted).toBe(true) - pending.resolve(provider) - await stopped - expect(provider.stop).toHaveBeenCalledTimes(1) - expect(runtimeBrowserCommandsFactoryIsHeadless()).toBe(false) -}) - -it('retains specific provider-unavailable diagnostics after discovery declines', async () => { - const { pending, startup } = delayedProvider() - setRuntimeBrowserUnavailableCause({ reason: 'driver_missing' }) - pending.resolve(null) - await startup.ready - expect(runtimeBrowserUnavailableCause()).toEqual({ reason: 'driver_missing' }) - await startup.stop() -}) - -it('does not hide resolver cleanup failures from the runtime lifetime', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { pending, startup } = delayedProvider() - pending.reject(new Error('cleanup failed')) - await startup.ready - await expect(startup.stop()).rejects.toThrow('cleanup failed') -}) - -it('propagates ready-provider cleanup failures', async () => { - const { pending, provider, startup } = delayedProvider() - vi.mocked(provider.stop).mockRejectedValueOnce(new Error('stop failed')) - pending.resolve(provider) - await startup.ready - await expect(startup.stop()).rejects.toThrow('stop failed') -}) - -it('refuses a member the provider does not implement instead of invoking it', async () => { - const { pending, provider, startup } = delayedProvider() - const commands = createRuntimeBrowserCommands(host) - pending.resolve(provider) - await startup.ready - // The fixture provider implements only browserTabList. - expect(() => commands.browserTabCreate({ worktree: 'wt-a' })).toThrow('Unknown browser command') - await startup.stop() -}) diff --git a/src/main/orcad/orcad-browser-startup.ts b/src/main/orcad/orcad-browser-startup.ts deleted file mode 100644 index fb7dc03a0ab5..000000000000 --- a/src/main/orcad/orcad-browser-startup.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { BrowserError } from '../browser/browser-error' -import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types' -import type { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser' -import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' -import { - resolveOrcadBrowserProvider, - type OrcadBrowserProvider, - type OrcadBrowserProviderOptions -} from './orcad-browser-provider' - -/** Browser discovery must not hold core RPC readiness hostage to a desktop authorization UI. */ -export function startOrcadBrowserProvider(options: OrcadBrowserProviderOptions): { - ready: Promise - stop(): Promise -} { - const controller = new AbortController() - let provider: OrcadBrowserProvider | null = null - let startupError: unknown - let stopping: Promise | undefined - setRuntimeBrowserCommandsFactory( - (host) => { - let commands: RuntimeBrowserCommands | undefined - // Every member resolves through the getter, so the target needs no members of its own. - const target: RuntimeBrowserCommands = Object.create(null) - return new Proxy(target, { - get: (_target, property) => { - if (property === 'then' || typeof property !== 'string') { - return undefined - } - return (...args: unknown[]) => { - if (controller.signal.aborted || !provider?.isAvailable()) { - throw new BrowserError( - BROWSER_UNAVAILABLE_ERROR_CODE, - 'Browser automation is unavailable on this host.' - ) - } - commands ??= provider.factory(host) - return callBrowserCommand(commands, property, args) - } - } - }) - }, - { headless: true, isAvailable: () => !controller.signal.aborted && !!provider?.isAvailable() } - ) - const ready = Promise.resolve() - .then(() => resolveOrcadBrowserProvider({ ...options, signal: controller.signal })) - .then( - (resolved) => { - provider = resolved - if (!resolved && !controller.signal.aborted) { - setRuntimeBrowserCommandsFactory(null) - } - }, - (error: unknown) => { - startupError = error - if (!controller.signal.aborted) { - setRuntimeBrowserCommandsFactory(null) - console.warn('[orcad] Browser startup failed:', error) - } - } - ) - return { - ready, - stop: () => { - controller.abort() - stopping ??= ready.then(async () => { - await provider?.stop() - // Unexpected resolver errors may include failed cleanup of a partially started provider. - if (startupError) { - throw startupError - } - }) - return stopping - } - } -} - -type BrowserCommandMember = (this: RuntimeBrowserCommands, ...args: unknown[]) => unknown - -function isBrowserCommandMember(value: unknown): value is BrowserCommandMember { - return typeof value === 'function' -} - -/** The proxy forwards by name; anything that is not a command method is refused, not invoked. */ -function callBrowserCommand( - commands: RuntimeBrowserCommands, - name: string, - args: unknown[] -): unknown { - const member: unknown = name in commands ? commands[name] : undefined - if (!isBrowserCommandMember(member)) { - throw new BrowserError(BROWSER_UNAVAILABLE_ERROR_CODE, `Unknown browser command: ${name}`) - } - return member.call(commands, ...args) -} diff --git a/src/main/orcad/orcad-completed-stop-receipt.ts b/src/main/orcad/orcad-completed-stop-receipt.ts deleted file mode 100644 index bf1ffc070b30..000000000000 --- a/src/main/orcad/orcad-completed-stop-receipt.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** Durable outcomes of a managed stop, in the data root beside the instance lock. */ -import { lstatSync } from 'node:fs' -import { dirname, join } from 'node:path' -import type { z } from 'zod' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { writeDurableSecureJsonFile } from '../../shared/secure-file' -import { - ORCAD_STOP_RECEIPTS_DIRNAME, - OrcadCompletedStopReceiptSchema, - OrcadManagedStopRequestSchema, - OrcadDaemonRetirementRecordSchema, - type OrcadCompletedStopReceipt, - type OrcadDaemonRetirementRecord, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' - -const RECEIPT_MAX_BYTES = 64 * 1024 - -type ReceiptKind = 'completed' | 'retirement' - -export function orcadStopReceiptPath(request: OrcadManagedStopRequest, kind: ReceiptKind): string { - const { transactionId, instance } = OrcadManagedStopRequestSchema.parse(request) - const suffix = kind === 'completed' ? '' : '.retirement' - return join( - dirname(instance.lockPath), - ORCAD_STOP_RECEIPTS_DIRNAME, - `${transactionId}${suffix}.json` - ) -} - -/** `null` when absent; a receipt for a different request throws rather than reading as absent. */ -function readReceipt( - request: OrcadManagedStopRequest, - kind: ReceiptKind, - schema: z.ZodType -): T | null { - const path = orcadStopReceiptPath(request, kind) - try { - if (!lstatSync(path).isFile()) { - throw new Error('orcad_stop_receipt_unverifiable') - } - } catch (error) { - if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { - return null - } - throw error - } - const receipt = schema.parse( - JSON.parse(readNodeFileSyncWithinLimit(path, RECEIPT_MAX_BYTES).buffer.toString('utf8')) - ) - const expected = OrcadManagedStopRequestSchema.parse(request) - if (JSON.stringify(receipt.request) !== JSON.stringify(expected)) { - throw new Error('orcad_stop_receipt_mismatch') - } - return receipt -} - -function writeReceipt(request: OrcadManagedStopRequest, kind: ReceiptKind, receipt: unknown): void { - if (!writeDurableSecureJsonFile(orcadStopReceiptPath(request, kind), receipt)) { - throw new Error('orcad_stop_receipt_permissions_unconfirmed') - } -} - -export function readOrcadCompletedStopReceipt( - request: OrcadManagedStopRequest -): OrcadCompletedStopReceipt | null { - return readReceipt(request, 'completed', OrcadCompletedStopReceiptSchema) -} - -/** - * Called only after exit is proven; rewriting an existing receipt re-runs its fsync. A retiring - * request whose orcad left no retirement record reports `unverifiable`, never `retired`. - */ -export function persistOrcadCompletedStopReceipt( - request: OrcadManagedStopRequest, - exitedAt: Date -): OrcadCompletedStopReceipt { - const parsed = OrcadManagedStopRequestSchema.parse(request) - const retirement = parsed.retireIdleDaemon - ? (readOrcadDaemonRetirementRecord(parsed)?.retirement ?? 'unverifiable') - : undefined - const receipt = readOrcadCompletedStopReceipt(parsed) ?? { - schemaVersion: 1 as const, - kind: 'orcad_managed_stop_completed' as const, - request: parsed, - exitedAt: exitedAt.toISOString(), - ...(retirement ? { retirement } : {}) - } - writeReceipt(parsed, 'completed', receipt) - return receipt -} - -export function readOrcadDaemonRetirementRecord( - request: OrcadManagedStopRequest -): OrcadDaemonRetirementRecord | null { - return readReceipt(request, 'retirement', OrcadDaemonRetirementRecordSchema) -} - -export function persistOrcadDaemonRetirementRecord( - request: OrcadManagedStopRequest, - outcome: Pick -): void { - writeReceipt(request, 'retirement', { - schemaVersion: 1, - kind: 'orcad_managed_stop_retirement', - request: OrcadManagedStopRequestSchema.parse(request), - ...outcome - }) -} diff --git a/src/main/orcad/orcad-daemon-retirement.test.ts b/src/main/orcad/orcad-daemon-retirement.test.ts deleted file mode 100644 index 01c0d780da53..000000000000 --- a/src/main/orcad/orcad-daemon-retirement.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('../daemon/daemon-init', () => ({ - requestIdleDaemonRetirement: vi.fn(), - listLiveDaemonSessions: vi.fn(), - releaseDaemonRetirementFence: vi.fn() -})) - -import type { DaemonIdleRetirementResult } from '../daemon/daemon-pty-runtime-state' -import { retireOrcadDaemonIfIdle } from './orcad-daemon-retirement' - -function ports( - result: () => Promise, - liveSessions: number | null = null -) { - return { - request: vi.fn(result), - releaseFence: vi.fn(), - countLiveSessions: vi.fn(async () => liveSessions), - timeoutMs: 20 - } -} - -describe('best-effort daemon retirement', () => { - it('reports retired only when the daemon accepted, and keeps its fence', async () => { - const retiring = ports(async () => ({ state: 'retiring' })) - expect(await retireOrcadDaemonIfIdle(retiring)).toMatchObject({ retirement: 'retired' }) - expect(retiring.releaseFence).not.toHaveBeenCalled() - }) - - it('leaves a busy daemon running, reopens admission, and reports live', async () => { - const busy = ports(async () => ({ state: 'busy', liveSessions: 3 })) - expect(await retireOrcadDaemonIfIdle(busy)).toMatchObject({ - retirement: 'live', - liveSessions: 3 - }) - expect(busy.releaseFence).toHaveBeenCalledOnce() - }) - - it('counts sessions itself when the daemon did not say how many', async () => { - const busy = ports(async () => ({ state: 'busy', liveSessions: null }), 1) - expect(await retireOrcadDaemonIfIdle(busy)).toMatchObject({ - retirement: 'live', - liveSessions: 1 - }) - }) - - it.each([ - [ - 'an unsupported daemon', - async (): Promise => ({ state: 'unsupported' }) - ], - [ - 'an unverifiable census', - async (): Promise => ({ state: 'unverifiable' }) - ], - [ - 'lost contact', - async (): Promise => { - throw new Error('socket closed') - } - ], - ['no answer', () => new Promise(() => {})] - ])('never reads %s as idle, and reopens admission', async (_name, result) => { - const unanswered = ports(result) - expect(await retireOrcadDaemonIfIdle(unanswered)).toMatchObject({ - retirement: 'unverifiable', - liveSessions: null - }) - expect(unanswered.releaseFence).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/orcad/orcad-daemon-retirement.ts b/src/main/orcad/orcad-daemon-retirement.ts deleted file mode 100644 index c5f0bb3936fe..000000000000 --- a/src/main/orcad/orcad-daemon-retirement.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Best-effort retirement of the terminal daemon when a managed stop asks for it. - * - * The daemon normally outlives orcad so terminals survive a restart (D7). Retirement happens - * only when the daemon itself proves it owns no live session across every generation; a busy - * or unanswering daemon stays up, and orcad's own stop never waits on that outcome. - */ -import { - listLiveDaemonSessions, - releaseDaemonRetirementFence, - requestIdleDaemonRetirement -} from '../daemon/daemon-init' -import type { OrcadDaemonRetirementVerdict } from '../../shared/orcad-stop-request' - -export const ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS = 5_000 - -export type OrcadDaemonRetirement = { - retirement: OrcadDaemonRetirementVerdict - liveSessions: number | null - reason: string | null -} - -/** Live sessions across every daemon generation, or `null` when any could not answer. */ -export async function countLiveOrcadDaemonSessions(): Promise { - try { - const sessions = await listLiveDaemonSessions() - return sessions ? sessions.filter((session) => session.isAlive).length : null - } catch { - return null - } -} - -type RetirementPorts = { - request: typeof requestIdleDaemonRetirement - releaseFence: typeof releaseDaemonRetirementFence - countLiveSessions: typeof countLiveOrcadDaemonSessions - timeoutMs: number -} - -const DEFAULT_PORTS: RetirementPorts = { - request: requestIdleDaemonRetirement, - releaseFence: releaseDaemonRetirementFence, - countLiveSessions: countLiveOrcadDaemonSessions, - timeoutMs: ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS -} - -export async function retireOrcadDaemonIfIdle( - ports: Partial = {} -): Promise { - const { request, releaseFence, countLiveSessions, timeoutMs } = { ...DEFAULT_PORTS, ...ports } - const result = await withTimeout( - request().catch(() => ({ state: 'unverifiable' as const })), - timeoutMs, - { state: 'timed-out' as const } - ) - if (result.state === 'retiring') { - return { retirement: 'retired', liveSessions: 0, reason: null } - } - // A daemon that stays must not be left refusing new terminals. - releaseFence() - const liveSessions = - result.state === 'busy' && result.liveSessions !== null - ? result.liveSessions - : await withTimeout(countLiveSessions(), timeoutMs, null) - if (liveSessions !== null && liveSessions > 0) { - return { - retirement: 'live', - liveSessions, - reason: - `${liveSessions} terminal ${liveSessions === 1 ? 'session is' : 'sessions are'} still ` + - 'live, so the daemon stays up and keeps them.' - } - } - return { - retirement: 'unverifiable', - liveSessions, - reason: - result.state === 'unsupported' - ? 'The terminal daemon predates idle retirement, so it was left running.' - : 'The host could not prove the daemon idle, so it was left running.' - } -} - -/** No answer within the bound is `fallback`; the caller treats it as unverifiable. */ -async function withTimeout(work: Promise, timeoutMs: number, fallback: F): Promise { - let timer: ReturnType | undefined - try { - return await Promise.race([ - work, - new Promise((resolve) => { - timer = setTimeout(() => resolve(fallback), timeoutMs) - }) - ]) - } finally { - clearTimeout(timer) - } -} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 2aa611658b79..854b4dfb723c 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -22,10 +22,6 @@ import { startOrcadWithHost } from './orcad-lifecycle' import { parseArgs } from './orcad-command-arguments' -import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime' -import { installOrcadStopRequestListeners } from './orcad-stop-request-listener' -import { prepareOrcadManagedStop } from './orcad-managed-stop-admission' -import type { OrcadManagedStopContext } from '../../shared/orcad-stop-request' import { changedAiVaultSearchSettings, type AiVaultSearchSettings @@ -41,18 +37,13 @@ function createNodeAppEnvironment(): AppEnvironment { // The main signal handler awaits runtime and browser teardown before process.exit. // Keep will-quit callbacks synchronous, but never let them pre-empt that async barrier. runOrcadQuitHandlers = (): void => { - const errors: unknown[] = [] for (const handler of quitHandlers.splice(0)) { try { handler() } catch (error) { - errors.push(error) + console.error('[orcad] shutdown handler failed:', error) } } - // Why throw: a quit handler that failed may leave a writer running, which keeps the lock. - if (errors.length > 0) { - throw new AggregateError(errors, 'orcad_quit_handlers_failed') - } } return { getPath: resolveOrcadPath, @@ -106,8 +97,6 @@ export type OrcadOptions = { export type OrcadHandle = { readiness: ServeReadiness - /** What an instance-bound stop request must name to stop this process. */ - managedStop: OrcadManagedStopContext stop(): Promise } @@ -118,7 +107,7 @@ export type OrcadHandle = { */ export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() - const { readiness, instance, stop } = await startOrcadWithHost( + return startOrcadWithHost( resolveUserDataPath(), (registerCleanup) => startOrcadRuntime(options, registerCleanup), () => { @@ -128,13 +117,11 @@ export async function startOrcad(options: OrcadOptions = {}): Promise {} } ) - const version = process.env.ORCA_VERSION ?? '0.0.0-orcad' - return { readiness, managedStop: { version, runtimeId: readiness.runtimeId, instance }, stop } } async function startOrcadRuntime( options: OrcadOptions, - registerCleanup: (cleanup: OrcadRuntimeCleanup) => void + registerCleanup: (cleanup: () => Promise) => void ): Promise> { const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') @@ -158,28 +145,35 @@ async function startOrcadRuntime( const { AgentStatusObservedPaneIdentities, AgentStatusObservedPaneIdentityCapture } = await import('../runtime/agent-status-observed-pane-identity') - const { disposeWatcherProcessAndWait } = await import('../ipc/parcel-watcher-process') - + let rpc: InstanceType | null = null let profileStoreForShutdown: | { flushFinalOrThrowAsync(): Promise; freezeWritesAsync(): Promise } | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} - // Cleanups run in reverse: RPC, then recovery and watchers, then the final flush, then daemon. - registerCleanup(() => agentHookServer.stop()) - registerCleanup(() => uninstallHookStatusRepublish()) - registerCleanup(() => uninstallObservedStatusIdentity()) - // Why disconnect and not shut down: the daemon must outlive this process, or an orcad - // restart goes back to killing every running terminal. - registerCleanup(() => stopOrcadDaemon()) registerCleanup(async () => { - // A SQLite-backed orcad has no JSON mirror to absorb a debounced write after SIGTERM. - if (profileStoreForShutdown) { - await flushOrcadProfileStoreForShutdown(profileStoreForShutdown) + try { + await rpc?.stop() + } finally { + try { + // Stop accepting RPC writes before the final persistence barrier. A SQLite-backed + // orcad has no JSON mirror to absorb a debounced write after SIGTERM. + if (profileStoreForShutdown) { + await flushOrcadProfileStoreForShutdown(profileStoreForShutdown) + } + } finally { + try { + // Why disconnect and not shut down: the daemon must outlive this process, or an + // orcad restart goes back to killing every running terminal. + await stopOrcadDaemon() + } finally { + uninstallObservedStatusIdentity() + uninstallHookStatusRepublish() + agentHookServer.stop() + } + } } }) - // Watcher children outlive a disposal that does not wait for them. - registerCleanup(() => disposeWatcherProcessAndWait()) const { DesktopPushService } = await import('../runtime/push/desktop-push-service') const { resolvePushGatewayOrigin } = await import('../runtime/push/push-gateway-origin') @@ -302,14 +296,11 @@ async function startOrcadRuntime( await runtime.refreshRestoredOrchestrationAuthority() await runtime.reconcileLegacyWorkerTerminals() - // A retry armed during recovery would otherwise write after the final profile flush. - registerCleanup(() => runtime.stopLegacyWorkerTerminalRecovery()) - // Recovery binds terminal and dispatch identities; only now can startup observations be fenced. observedStatusCapture.attach(runtime) const bindHost = resolveOrcadBindHost(options.bind) - const rpc = new OrcaRuntimeRpcServer({ + rpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: runtimeUserDataPath, enableWebSocket: true, @@ -320,8 +311,6 @@ async function startOrcadRuntime( pinnedBindHost: bindHost, ...(options.port !== undefined ? { wsPort: options.port, preferPinnedWsPort: true } : {}) }) - // Stops first: no RPC may write while the rest of the runtime is torn down. - registerCleanup(() => rpc.stop()) await rpc.start() const pushService = DesktopPushService.create({ runtime, @@ -399,12 +388,6 @@ export { ORCAD_SHUTDOWN_DEADLINE_MS } from './orcad-lifecycle' export async function main(argv: string[] = process.argv.slice(2)): Promise { const startup = startOrcad(parseArgs(argv)) - const requestShutdown = installOrcadShutdownSignals(async () => (await startup).stop()) - const handle = await startup - // Why after startup: a managed request must name the runtime and instance this run became. - installOrcadStopRequestListeners(() => requestShutdown('stop request'), { - installRoot: resolveOrcadInstallRoot(), - managedStop: handle.managedStop, - beforeManagedStop: prepareOrcadManagedStop - }) + installOrcadShutdownSignals(async () => (await startup).stop()) + await startup } diff --git a/src/main/orcad/orcad-instance-lock.test.ts b/src/main/orcad/orcad-instance-lock.test.ts index 354976a72ba4..45f7543c5157 100644 --- a/src/main/orcad/orcad-instance-lock.test.ts +++ b/src/main/orcad/orcad-instance-lock.test.ts @@ -3,7 +3,6 @@ import { mkdirSync, mkdtempSync, readFileSync, - renameSync, rmSync, statSync, writeFileSync @@ -15,8 +14,7 @@ import { acquireOrcadInstanceLock, ORCAD_LOCK_FILE_NAME, OrcadInstanceLockError, - type OrcadInstanceLockHooks, - type OrcadLockRecord + type OrcadInstanceLockHooks } from './orcad-instance-lock' const roots: string[] = [] @@ -39,18 +37,6 @@ function hooks(overrides: OrcadInstanceLockHooks = {}): OrcadInstanceLockHooks { } } -function persistedRecord(overrides: Partial = {}): OrcadLockRecord { - return { - pid: 424242, - startedAtMs: 1, - identity: 'uid-1000', - version: '1.0.0-test', - acquiredAt: '2026-01-01T00:00:00.000Z', - nonce: 'stale', - ...overrides - } -} - afterEach(() => { for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) @@ -82,14 +68,20 @@ describe('acquireOrcadInstanceLock', () => { it('reclaims the record of a holder that is gone', () => { const root = makeRoot() - writeFileSync(join(root, ORCAD_LOCK_FILE_NAME), JSON.stringify(persistedRecord())) + writeFileSync( + join(root, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ pid: 424242, identity: 'uid-1000', startedAtMs: 1, nonce: 'stale' }) + ) const lock = acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => false })) expect(JSON.parse(readFileSync(lock.path, 'utf8')).pid).toBe(process.pid) }) it('treats a live pid whose start time does not match as a recycled pid, not a holder', () => { const root = makeRoot() - writeFileSync(join(root, ORCAD_LOCK_FILE_NAME), JSON.stringify(persistedRecord())) + writeFileSync( + join(root, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ pid: 424242, identity: 'uid-1000', startedAtMs: 1, nonce: 'stale' }) + ) const lock = acquireOrcadInstanceLock( root, hooks({ processIsAlive: () => true, startTimeMatches: () => false }) @@ -97,37 +89,11 @@ describe('acquireOrcadInstanceLock', () => { expect(JSON.parse(readFileSync(lock.path, 'utf8')).pid).toBe(process.pid) }) - it('does not displace a successor published between stale inspection and reclaim', () => { - const root = makeRoot() - const lockPath = join(root, ORCAD_LOCK_FILE_NAME) - const successor = persistedRecord({ pid: 777, startedAtMs: 2, nonce: 'successor' }) - writeFileSync(lockPath, JSON.stringify(persistedRecord())) - let raced = false - const lockHooks = hooks({ - processIsAlive: () => { - if (!raced) { - raced = true - // Simulate a contender replacing the stale record and publishing its own lock - // after this process inspected liveness but before it renames the entry. - const displacedPath = `${lockPath}.displaced` - renameSync(lockPath, displacedPath) - writeFileSync(lockPath, JSON.stringify(successor), { flag: 'wx', mode: 0o600 }) - } - return false - } - }) - - expect(() => acquireOrcadInstanceLock(root, lockHooks)).toThrow( - expect.objectContaining({ code: 'orcad_instance_lock_held' }) - ) - expect(JSON.parse(readFileSync(lockPath, 'utf8')).nonce).toBe('successor') - }) - it('never reclaims a lock held by a different identity, even a dead one', () => { const root = makeRoot() writeFileSync( join(root, ORCAD_LOCK_FILE_NAME), - JSON.stringify(persistedRecord({ identity: 'uid-2000', nonce: 'other' })) + JSON.stringify({ pid: 424242, identity: 'uid-2000', startedAtMs: 1, nonce: 'other' }) ) expect(() => acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => false }))).toThrow( expect.objectContaining({ code: 'orcad_instance_lock_foreign_identity' }) @@ -140,47 +106,12 @@ describe('acquireOrcadInstanceLock', () => { // A successor reclaimed the root while this process was wedged. writeFileSync( lock.path, - JSON.stringify(persistedRecord({ pid: 777, startedAtMs: 2, nonce: 'successor' })) + JSON.stringify({ pid: 777, identity: 'uid-1000', startedAtMs: 2, nonce: 'successor' }) ) lock.release() expect(JSON.parse(readFileSync(lock.path, 'utf8')).nonce).toBe('successor') }) - it.each([ - ['invalid JSON', '{'], - ['an incomplete record', JSON.stringify({ pid: 424242, identity: 'uid-1000' })], - ['an invalid pid', JSON.stringify(persistedRecord({ pid: -1 }))], - ['an invalid start time', JSON.stringify({ ...persistedRecord(), startedAtMs: 'yesterday' })] - ])('fails closed when the existing lock contains %s', (_label, contents) => { - const root = makeRoot() - writeFileSync(join(root, ORCAD_LOCK_FILE_NAME), contents) - - expect(() => acquireOrcadInstanceLock(root, hooks())).toThrow( - expect.objectContaining({ code: 'orcad_instance_lock_unreadable' }) - ) - expect(readFileSync(join(root, ORCAD_LOCK_FILE_NAME), 'utf8')).toBe(contents) - }) - - it('fails closed when the existing lock is not a regular file', () => { - const root = makeRoot() - mkdirSync(join(root, ORCAD_LOCK_FILE_NAME)) - - expect(() => acquireOrcadInstanceLock(root, hooks())).toThrow( - expect.objectContaining({ code: 'orcad_instance_lock_unreadable' }) - ) - }) - - it('fails closed without reading an oversized lock into memory', () => { - const root = makeRoot() - const lockPath = join(root, ORCAD_LOCK_FILE_NAME) - writeFileSync(lockPath, 'x'.repeat(64 * 1024 + 1)) - - expect(() => acquireOrcadInstanceLock(root, hooks())).toThrow( - expect.objectContaining({ code: 'orcad_instance_lock_unreadable' }) - ) - expect(statSync(lockPath).size).toBe(64 * 1024 + 1) - }) - it.runIf(process.platform !== 'win32')( 'tightens a group/world-accessible data root rather than refusing when it can', () => { diff --git a/src/main/orcad/orcad-instance-lock.ts b/src/main/orcad/orcad-instance-lock.ts index 6e913b5cb22f..052140bcd2ea 100644 --- a/src/main/orcad/orcad-instance-lock.ts +++ b/src/main/orcad/orcad-instance-lock.ts @@ -15,8 +15,8 @@ import { randomUUID } from 'node:crypto' import { chmodSync, - linkSync, mkdirSync, + readFileSync, renameSync, statSync, unlinkSync, @@ -25,12 +25,9 @@ import { import { userInfo } from 'node:os' import { join } from 'node:path' import process from 'node:process' -import { z } from 'zod' import { getProcessStartedAtMs, startTimeMatches } from '../daemon/daemon-process-start-time' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' export const ORCAD_LOCK_FILE_NAME = 'orcad.lock' -const MAX_ORCAD_LOCK_BYTES = 64 * 1024 export type OrcadInstanceLockCode = | 'orcad_data_root_unusable' @@ -38,7 +35,6 @@ export type OrcadInstanceLockCode = | 'orcad_data_root_shared' | 'orcad_instance_lock_held' | 'orcad_instance_lock_foreign_identity' - | 'orcad_instance_lock_unreadable' export class OrcadInstanceLockError extends Error { constructor( @@ -50,23 +46,16 @@ export class OrcadInstanceLockError extends Error { } } -const OrcadLockRecordSchema = z.object({ - pid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), +export type OrcadLockRecord = { + pid: number /** Null where the platform cannot read it; PID alone is then the (weaker) fence. */ - startedAtMs: z.number().finite().nonnegative().nullable(), + startedAtMs: number | null /** POSIX uid, or the Windows username. Compared as an opaque string. */ - identity: z.string().min(1).max(1_024), - version: z.string().min(1).max(255), - acquiredAt: z.iso.datetime({ offset: true }), + identity: string + version: string + acquiredAt: string /** Distinguishes our record from a replacement written after we lost the race. */ - nonce: z.string().min(1).max(255) -}) - -export type OrcadLockRecord = z.infer - -/** `null` when absent, unreadable, oversized or malformed; callers must not read that as free. */ -export function readOrcadInstanceLockRecord(path: string): OrcadLockRecord | null { - return parseLockRecord(readBoundedLockFile(path) ?? '') + nonce: string } export type OrcadInstanceLock = { @@ -108,8 +97,22 @@ function isErrorCode(error: unknown, code: string): boolean { function parseLockRecord(content: string): OrcadLockRecord | null { try { - const result = OrcadLockRecordSchema.safeParse(JSON.parse(content)) - return result.success ? result.data : null + const parsed: unknown = JSON.parse(content) + if (!parsed || typeof parsed !== 'object') { + return null + } + const record = parsed as Partial + if (typeof record.pid !== 'number' || typeof record.identity !== 'string') { + return null + } + return { + pid: record.pid, + startedAtMs: typeof record.startedAtMs === 'number' ? record.startedAtMs : null, + identity: record.identity, + version: typeof record.version === 'string' ? record.version : 'unknown', + acquiredAt: typeof record.acquiredAt === 'string' ? record.acquiredAt : '', + nonce: typeof record.nonce === 'string' ? record.nonce : '' + } } catch { return null } @@ -229,17 +232,8 @@ export function acquireOrcadInstanceLock( return makeLock(lockPath, record) } - const existing = parseLockRecord(readBoundedLockFile(lockPath) ?? '') - if (!existing) { - // Why fail closed: an unreadable record proves nothing about its holder having exited. - throw new OrcadInstanceLockError( - 'orcad_instance_lock_unreadable', - `The orcad instance lock at ${lockPath} is unreadable, malformed, or larger than ` + - `${MAX_ORCAD_LOCK_BYTES} bytes. Refusing to reclaim it without proof that its holder ` + - 'has exited. Stop orcad and remove the stale lock manually.' - ) - } - if (existing.identity !== identity) { + const existing = parseLockRecord(safeRead(lockPath) ?? '') + if (existing && existing.identity !== identity) { throw new OrcadInstanceLockError( 'orcad_instance_lock_foreign_identity', `The orcad data root ${dataRoot} is locked by identity ${existing.identity} (pid ` + @@ -247,7 +241,7 @@ export function acquireOrcadInstanceLock( 'root corrupts it. Give each its own ORCA_USER_DATA.' ) } - if (isAlive(existing.pid) && matchesStartTime(existing.pid, existing.startedAtMs)) { + if (existing && isAlive(existing.pid) && matchesStartTime(existing.pid, existing.startedAtMs)) { throw new OrcadInstanceLockError( 'orcad_instance_lock_held', `Another orcad (pid ${existing.pid}, started ${existing.acquiredAt || 'unknown'}) already ` + @@ -255,6 +249,13 @@ export function acquireOrcadInstanceLock( 'ORCA_USER_DATA.' ) } + if (!existing) { + console.warn( + `[orcad] The instance lock at ${lockPath} is unreadable; reclaiming it. If another orcad ` + + 'is running on this data root, stop it now.' + ) + } + // Why rename-and-then-publish rather than unlink-and-write: rename claims one exact // directory entry, so a replacement written between our read and our write stays at the // canonical path and wins — we never delete a record we did not inspect. @@ -268,15 +269,6 @@ export function acquireOrcadInstanceLock( 'holding it. Retry, or stop the other orcad.' ) } - // A contender may have replaced the entry after the liveness check; never displace its record. - const claimedContents = readBoundedLockFile(claimPath) - if (parseLockRecord(claimedContents ?? '')?.nonce !== existing.nonce) { - restoreDisplacedLock(claimPath, lockPath, claimedContents) - throw new OrcadInstanceLockError( - 'orcad_instance_lock_held', - `The orcad instance lock at ${lockPath} changed while reclaiming a stale record.` - ) - } if (!publish()) { // Someone else claimed it first. Their record is authoritative; ours is not. try { @@ -297,32 +289,9 @@ export function acquireOrcadInstanceLock( return makeLock(lockPath, record) } -/** No-clobber restore: a third contender's newer record at the canonical path stays authoritative. */ -function restoreDisplacedLock( - claimPath: string, - lockPath: string, - claimedContents: string | null -): void { - try { - linkSync(claimPath, lockPath) - unlinkSync(claimPath) - } catch { - if (claimedContents === null) { - return - } - try { - writeFileSync(lockPath, claimedContents, { flag: 'wx', mode: 0o600 }) - unlinkSync(claimPath) - } catch { - // A newer contender won, or restoration is unavailable; fail closed. - } - } -} - -function readBoundedLockFile(path: string): string | null { +function safeRead(path: string): string | null { try { - const { buffer, stats } = readNodeFileSyncWithinLimit(path, MAX_ORCAD_LOCK_BYTES) - return stats.isFile() ? buffer.toString('utf8') : null + return readFileSync(path, 'utf8') } catch { return null } @@ -341,7 +310,7 @@ function makeLock(lockPath: string, record: OrcadLockRecord): OrcadInstanceLock // Why re-read before unlinking: a reclaim by a later orcad (after, say, a SIGKILL that // this process somehow survived enough to run handlers) leaves a record that is not // ours. Deleting it would unlock a live runtime. - const current = parseLockRecord(readBoundedLockFile(lockPath) ?? '') + const current = parseLockRecord(safeRead(lockPath) ?? '') if (!current || current.nonce !== record.nonce) { return } diff --git a/src/main/orcad/orcad-lifecycle-host.test.ts b/src/main/orcad/orcad-lifecycle-host.test.ts deleted file mode 100644 index 58004faafa55..000000000000 --- a/src/main/orcad/orcad-lifecycle-host.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' - -vi.mock('./orcad-browser-startup', () => ({ - startOrcadBrowserProvider: () => ({ ready: Promise.resolve(), stop: async () => {} }) -})) - -import { ORCAD_LOCK_FILE_NAME, readOrcadInstanceLockRecord } from './orcad-instance-lock' -import { startOrcadWithHost } from './orcad-lifecycle' - -const roots: string[] = [] -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function dataRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'orcad-lifecycle-host-')) - roots.push(root) - return root -} - -describe('startOrcadWithHost', () => { - it('names the instance a managed stop must address, and releases its lock on a clean stop', async () => { - const root = dataRoot() - const handle = await startOrcadWithHost( - root, - async () => ({}), - () => {} - ) - expect(readOrcadInstanceLockRecord(handle.instance.lockPath)).toMatchObject({ - pid: handle.instance.pid, - nonce: handle.instance.nonce - }) - await handle.stop() - expect(existsSync(join(root, ORCAD_LOCK_FILE_NAME))).toBe(false) - }) - - it('keeps the instance lock when a runtime writer could not be stopped', async () => { - const root = dataRoot() - const failure = new Error('profile writer still running') - const handle = await startOrcadWithHost( - root, - async (registerCleanup) => { - registerCleanup(() => { - throw failure - }) - return {} - }, - () => {} - ) - await expect(handle.stop()).rejects.toBe(failure) - expect(existsSync(join(root, ORCAD_LOCK_FILE_NAME))).toBe(true) - }) - - it('keeps the instance lock when a quit handler fails', async () => { - const root = dataRoot() - const handle = await startOrcadWithHost( - root, - async () => ({}), - () => { - throw new AggregateError([new Error('handler')], 'orcad_quit_handlers_failed') - } - ) - await expect(handle.stop()).rejects.toThrow('orcad_quit_handlers_failed') - expect(existsSync(join(root, ORCAD_LOCK_FILE_NAME))).toBe(true) - }) -}) diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index ffcec0224a3e..bfff3b7f14ed 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -1,7 +1,5 @@ import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' -import { startOrcadBrowserProvider } from './orcad-browser-startup' -import { OrcadRuntimeLifetime, type OrcadRuntimeCleanup } from './orcad-runtime-lifetime' -import type { OrcadManagedStopInstance } from '../../shared/orcad-stop-request' +import { resolveOrcadBrowserProvider } from './orcad-browser-provider' import { acquireOrcadInstanceLock } from './orcad-instance-lock' import { ORCAD_BUNDLED_LAUNCHER_ENV } from './orcad-bundled-runtime' import { resolveOrcadExitCode } from './orcad-exit-code' @@ -23,14 +21,11 @@ function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promi export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 -/** - * A launcher and its child can both receive the same process-group or service stop signal. - * Returns the trigger stop-request listeners share, so every source runs one bounded stop. - */ +/** A launcher and its child can both receive the same process-group or service stop signal. */ export function installOrcadShutdownSignals( stop: () => Promise, deadlineMs = ORCAD_SHUTDOWN_DEADLINE_MS -): (reason: string) => void { +): void { let stopping = false const shutdown = (signal: string): void => { if (stopping) { @@ -60,26 +55,26 @@ export function installOrcadShutdownSignals( shutdown('launcher disconnect') } } - return shutdown } export async function startOrcadWithLifecycle( - start: (registerRuntimeCleanup: (cleanup: OrcadRuntimeCleanup) => void) => Promise, + start: (registerRuntimeCleanup: (cleanup: () => Promise) => void) => Promise, cleanupHost: (runtimeCleanupSucceeded: boolean) => Promise ): Promise }> { - // Runtime resources stop in reverse registration order before any host resource. - const runtime = new OrcadRuntimeLifetime() + let cleanupRuntime = async (): Promise => {} const cleanup = createIdempotentOrcadCleanup(async () => { let runtimeCleanupSucceeded = false try { - await runtime.stop() + await cleanupRuntime() runtimeCleanupSucceeded = true } finally { await cleanupHost(runtimeCleanupSucceeded) } }) try { - const handle = await start((nextCleanup) => runtime.add(nextCleanup)) + const handle = await start((nextCleanup) => { + cleanupRuntime = nextCleanup + }) return { ...handle, stop: cleanup } } catch (error) { try { @@ -92,40 +87,41 @@ export async function startOrcadWithLifecycle( } } -/** Keep profile admission and the instance lock until every runtime writer has stopped. */ +/** Keep profile admission until every runtime writer has stopped. */ export async function startOrcadWithHost( userDataPath: string, - start: (registerCleanup: (cleanup: OrcadRuntimeCleanup) => void) => Promise, + start: (registerCleanup: (cleanup: () => Promise) => void) => Promise, runQuitHandlers: () => void -): Promise; instance: OrcadManagedStopInstance }> { +): Promise }> { const instanceLock = acquireOrcadInstanceLock(userDataPath) - const { pid, startedAtMs, nonce } = instanceLock.record - const instance = { pid, startedAtMs, nonce, lockPath: instanceLock.path } let admission: ProfileStateRuntimeAdmission | undefined - let browserProvider: ReturnType | undefined + let browserProvider: Awaited> | undefined return startOrcadWithLifecycle( async (registerCleanup) => { admission = acquireProfileStateRuntimeAdmission(userDataPath) - // Why not awaited: a desktop sidecar's authorization UI must not hold RPC readiness hostage. - browserProvider = startOrcadBrowserProvider({ userDataPath }) - return { ...(await start(registerCleanup)), instance } + browserProvider = await resolveOrcadBrowserProvider({ userDataPath }) + const provider = browserProvider + setRuntimeBrowserCommandsFactory(provider?.factory ?? null, { + headless: provider !== null, + ...(provider ? { isAvailable: () => provider.isAvailable() } : {}) + }) + return start(registerCleanup) }, async (runtimeCleanupSucceeded) => { - // Failed teardown keeps both fences until the process actually exits. - const host = new OrcadRuntimeLifetime(() => { - if (runtimeCleanupSucceeded) { + try { + await browserProvider?.stop() + } finally { + setRuntimeBrowserCommandsFactory(null) + runQuitHandlers() + try { + // Failed teardown excludes recovery until the process actually exits. + if (runtimeCleanupSucceeded) { + admission?.release() + } + } finally { instanceLock.release() } - }) - host.add(({ failed }) => { - if (runtimeCleanupSucceeded && !failed) { - admission?.release() - } - }) - host.add(() => runQuitHandlers()) - host.add(() => setRuntimeBrowserCommandsFactory(null)) - host.add(() => browserProvider?.stop()) - await host.stop() + } } ) } diff --git a/src/main/orcad/orcad-managed-stop-admission.ts b/src/main/orcad/orcad-managed-stop-admission.ts deleted file mode 100644 index e354266853ea..000000000000 --- a/src/main/orcad/orcad-managed-stop-admission.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { OrcadManagedStopRequest } from '../../shared/orcad-stop-request' -import { persistOrcadDaemonRetirementRecord } from './orcad-completed-stop-receipt' -import type { OrcadDaemonRetirement } from './orcad-daemon-retirement' - -// Lazy like the rest of orcad's daemon graph: the entry module must not load it at import time. -async function retireLazily(): Promise { - const { retireOrcadDaemonIfIdle } = await import('./orcad-daemon-retirement') - return retireOrcadDaemonIfIdle() -} - -/** Runs before orcad stops for a managed request; it can record an outcome but never veto. */ -export async function prepareOrcadManagedStop( - request: OrcadManagedStopRequest, - retire: () => Promise = retireLazily -): Promise { - if (!request.retireIdleDaemon) { - return - } - const outcome = await retire().catch((error: unknown): OrcadDaemonRetirement => ({ - retirement: 'unverifiable', - liveSessions: null, - reason: `Retirement failed: ${error instanceof Error ? error.message : String(error)}` - })) - try { - persistOrcadDaemonRetirementRecord(request, outcome) - } catch (error) { - // The completion command reads a missing record as `unverifiable`. - console.error('[orcad] could not record daemon retirement:', error) - } -} diff --git a/src/main/orcad/orcad-managed-stop-command.ts b/src/main/orcad/orcad-managed-stop-command.ts deleted file mode 100644 index d6f05fdd6ee0..000000000000 --- a/src/main/orcad/orcad-managed-stop-command.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * `orcad --complete-managed-stop `: one stop, one JSON verdict line on stdout. - * - * Exit 0 means a verdict was printed — read it, the exit code does not carry it. 64 is a - * malformed invocation; 1 is a failure before any verdict, which is never evidence of exit. - */ -import { - ORCAD_COMPLETE_MANAGED_STOP_FLAG, - OrcadManagedStopRequestSchema, - type OrcadManagedStopCompletion -} from '../../shared/orcad-stop-request' -import { ZodError } from 'zod' -import { - completeOrcadManagedStop, - type OrcadManagedStopCompletionOptions -} from './orcad-managed-stop-completion' - -export async function runOrcadManagedStopCommand( - argv: readonly string[], - options: OrcadManagedStopCompletionOptions = {} -): Promise { - if (argv.length !== 2 || argv[0] !== ORCAD_COMPLETE_MANAGED_STOP_FLAG || !argv[1]) { - throw new Error('orcad_managed_stop_invalid_arguments') - } - const request = OrcadManagedStopRequestSchema.parse(JSON.parse(argv[1])) - const verdict = await completeOrcadManagedStop(request, options) - const completion: OrcadManagedStopCompletion = { - ...request, - kind: 'orcad_managed_stop_completion', - verdict, - receiptPersisted: verdict === 'exited' - } - process.stdout.write(`${JSON.stringify(completion)}\n`) - return completion -} - -export const ORCAD_MANAGED_STOP_EXIT_VERDICT = 0 -export const ORCAD_MANAGED_STOP_EXIT_FAILED = 1 -export const ORCAD_MANAGED_STOP_EXIT_USAGE = 64 - -export async function runOrcadManagedStopCommandAndExit(argv: readonly string[]): Promise { - let code = ORCAD_MANAGED_STOP_EXIT_VERDICT - try { - await runOrcadManagedStopCommand(argv) - } catch (error) { - console.error('orcad: managed stop failed:', error) - code = - error instanceof ZodError || - error instanceof SyntaxError || - (error instanceof Error && error.message === 'orcad_managed_stop_invalid_arguments') - ? ORCAD_MANAGED_STOP_EXIT_USAGE - : ORCAD_MANAGED_STOP_EXIT_FAILED - } - // Why exit after the write drains: the caller reads stdout to EOF. - process.stdout.write('', () => process.exit(code)) -} diff --git a/src/main/orcad/orcad-managed-stop-completion.ts b/src/main/orcad/orcad-managed-stop-completion.ts deleted file mode 100644 index 0cdde251e364..000000000000 --- a/src/main/orcad/orcad-managed-stop-completion.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Asking one orcad instance to stop and proving that it did. - * - * Runs as a short-lived command on the execution host. It never signals: it writes the - * instance-bound request the running orcad watches for, then observes until the instance is - * gone. `exited` needs proof — no process with that PID, or a PID whose start time shows it now - * belongs to another process. Anything the host cannot answer is `unverifiable`, never exit. - */ -import { setTimeout as delay } from 'node:timers/promises' -import { writeDurableSecureJsonFile } from '../../shared/secure-file' -import { - OrcadManagedStopRequestSchema, - type OrcadManagedStopInstance, - type OrcadManagedStopRequest, - type OrcadManagedStopVerdict -} from '../../shared/orcad-stop-request' -import { - getProcessStartedAtMs, - START_TIME_TOLERANCE_MS, - startTimesWithinTolerance -} from '../daemon/daemon-process-start-time' -import { persistOrcadCompletedStopReceipt } from './orcad-completed-stop-receipt' -import { - orcadInstanceLockNames, - orcadManagedStopRequestPath, - readOrcadManagedStopRequest -} from './orcad-managed-stop-request' - -export type OrcadProcessProbe = (pid: number) => 'alive' | 'missing' | 'unverifiable' - -export type OrcadManagedStopCompletionOptions = { - probeProcess?: OrcadProcessProbe - startedAtMs?: (pid: number) => number | null - sleep?: () => Promise - attempts?: number - now?: () => Date -} - -function defaultProbe(pid: number): ReturnType { - try { - process.kill(pid, 0) - return 'alive' - } catch (error) { - const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : null - // EPERM proves some process holds the PID; it cannot prove ours exited. - return code === 'ESRCH' ? 'missing' : 'unverifiable' - } -} - -function observeInstance( - instance: OrcadManagedStopInstance, - options: OrcadManagedStopCompletionOptions -): OrcadManagedStopVerdict { - const probe = (options.probeProcess ?? defaultProbe)(instance.pid) - if (probe !== 'alive') { - return probe === 'missing' ? 'exited' : 'unverifiable' - } - const actual = (options.startedAtMs ?? getProcessStartedAtMs)(instance.pid) - // A reused PID is proof of exit only when both start times are known and disagree. - if ( - actual !== null && - instance.startedAtMs !== null && - !startTimesWithinTolerance(actual, instance.startedAtMs, START_TIME_TOLERANCE_MS) - ) { - return 'exited' - } - return 'live' -} - -/** Writes the request only while the lock still names this instance, then waits for exit. */ -export async function completeOrcadManagedStop( - input: OrcadManagedStopRequest, - options: OrcadManagedStopCompletionOptions = {} -): Promise { - const request = OrcadManagedStopRequestSchema.parse(input) - const attempts = options.attempts ?? 80 - if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 240) { - throw new Error('orcad_managed_stop_invalid_attempts') - } - const completed = (): 'exited' => { - persistOrcadCompletedStopReceipt(request, (options.now ?? (() => new Date()))()) - return 'exited' - } - let verdict = observeInstance(request.instance, options) - if (verdict !== 'live') { - return verdict === 'exited' ? completed() : verdict - } - if (!lockStillNamesInstance(request.instance)) { - // The process lives but no longer owns the lock it published: it is not ours to address. - return 'unverifiable' - } - const requestPath = orcadManagedStopRequestPath(request.instance) - if (!existingRequestMatches(requestPath, request)) { - return 'unverifiable' - } - if (!writeDurableSecureJsonFile(requestPath, request)) { - throw new Error('orcad_managed_stop_request_permissions_unconfirmed') - } - for (let attempt = 0; attempt < attempts; attempt++) { - await (options.sleep ?? (() => delay(250)))() - verdict = observeInstance(request.instance, options) - if (verdict !== 'live') { - return verdict === 'exited' ? completed() : verdict - } - } - return 'live' -} - -function lockStillNamesInstance(instance: OrcadManagedStopInstance): boolean { - try { - return orcadInstanceLockNames(instance) - } catch { - return false - } -} - -/** A request already present must be this one; another transaction's request is not ours. */ -function existingRequestMatches(path: string, request: OrcadManagedStopRequest): boolean { - try { - return JSON.stringify(readOrcadManagedStopRequest(path)) === JSON.stringify(request) - } catch (error) { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' - } -} diff --git a/src/main/orcad/orcad-managed-stop-request.ts b/src/main/orcad/orcad-managed-stop-request.ts deleted file mode 100644 index ffd7560cae0d..000000000000 --- a/src/main/orcad/orcad-managed-stop-request.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** Validating an instance-bound stop request before the running orcad acts on it. */ -import { createHash } from 'node:crypto' -import { lstatSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { - ORCAD_MANAGED_STOP_REQUEST_MAX_BYTES, - ORCAD_MANAGED_STOP_REQUEST_PREFIX, - OrcadManagedStopRequestSchema, - type OrcadManagedStopContext, - type OrcadManagedStopInstance, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { readOrcadInstanceLockRecord } from './orcad-instance-lock' - -/** Keyed by the lock nonce, so a request for a previous instance is never this one's. */ -export function orcadManagedStopRequestPath(instance: OrcadManagedStopInstance): string { - const digest = createHash('sha256').update(instance.nonce).digest('hex') - return join(dirname(instance.lockPath), `${ORCAD_MANAGED_STOP_REQUEST_PREFIX}.${digest}`) -} - -export function readOrcadManagedStopRequest(path: string): OrcadManagedStopRequest { - if (!lstatSync(path).isFile()) { - throw new Error('orcad_managed_stop_request_not_regular_file') - } - const { buffer } = readNodeFileSyncWithinLimit(path, ORCAD_MANAGED_STOP_REQUEST_MAX_BYTES) - return OrcadManagedStopRequestSchema.parse(JSON.parse(buffer.toString('utf8'))) -} - -export function sameOrcadManagedStopInstance( - left: OrcadManagedStopInstance, - right: Pick -): boolean { - return ( - left.pid === right.pid && left.startedAtMs === right.startedAtMs && left.nonce === right.nonce - ) -} - -/** Whether the instance lock still names exactly this instance. */ -export function orcadInstanceLockNames(instance: OrcadManagedStopInstance): boolean { - if (!lstatSync(instance.lockPath).isFile()) { - return false - } - const record = readOrcadInstanceLockRecord(instance.lockPath) - return record !== null && sameOrcadManagedStopInstance(instance, record) -} - -/** Throws unless the request names this running instance and its lock is still ours. */ -export function validateOrcadManagedStopRequest( - context: OrcadManagedStopContext, - requestPath: string -): OrcadManagedStopRequest { - const request = readOrcadManagedStopRequest(requestPath) - if ( - request.version !== context.version || - request.runtimeId !== context.runtimeId || - request.instance.lockPath !== context.instance.lockPath || - !sameOrcadManagedStopInstance(request.instance, context.instance) - ) { - throw new Error('orcad_managed_stop_request_identity_mismatch') - } - if (!orcadInstanceLockNames(context.instance)) { - throw new Error('orcad_managed_stop_instance_lock_changed') - } - return request -} diff --git a/src/main/orcad/orcad-managed-stop.test.ts b/src/main/orcad/orcad-managed-stop.test.ts deleted file mode 100644 index 21bf8375ede8..000000000000 --- a/src/main/orcad/orcad-managed-stop.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - OrcadManagedStopCompletionSchema, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { acquireOrcadInstanceLock, type OrcadInstanceLock } from './orcad-instance-lock' -import { - orcadManagedStopRequestPath, - validateOrcadManagedStopRequest -} from './orcad-managed-stop-request' -import { - completeOrcadManagedStop, - type OrcadManagedStopCompletionOptions -} from './orcad-managed-stop-completion' -import { - orcadStopReceiptPath, - readOrcadCompletedStopReceipt, - readOrcadDaemonRetirementRecord -} from './orcad-completed-stop-receipt' -import { prepareOrcadManagedStop } from './orcad-managed-stop-admission' -import { runOrcadManagedStopCommand } from './orcad-managed-stop-command' - -const roots: string[] = [] -afterEach(() => { - vi.restoreAllMocks() - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function running(): { lock: OrcadInstanceLock; request: OrcadManagedStopRequest } { - const root = mkdtempSync(join(tmpdir(), 'orcad-managed-stop-')) - roots.push(root) - const lock = acquireOrcadInstanceLock(root, { - identity: () => 'uid-1000', - version: () => '1.0.0', - startedAtMs: () => 5_000 - }) - const { pid, startedAtMs, nonce } = lock.record - return { - lock, - request: { - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - version: '1.0.0', - runtimeId: 'runtime-1', - instance: { pid, startedAtMs, nonce, lockPath: lock.path } - } - } -} - -const context = ({ version, runtimeId, instance }: OrcadManagedStopRequest) => ({ - version, - runtimeId, - instance -}) - -function options( - overrides: Partial = {} -): OrcadManagedStopCompletionOptions { - return { - probeProcess: () => 'alive', - startedAtMs: () => 5_000, - sleep: async () => {}, - attempts: 3, - now: () => new Date('2026-10-01T00:00:00.000Z'), - ...overrides - } -} - -describe('managed stop requests', () => { - it('keys the request file to the instance, so a previous instance never matches', () => { - const { request } = running() - const other = { ...request.instance, nonce: 'other' } - expect(orcadManagedStopRequestPath(request.instance)).not.toBe( - orcadManagedStopRequestPath(other) - ) - }) - - it('accepts only a request naming this runtime, version and instance', () => { - const { request } = running() - const path = orcadManagedStopRequestPath(request.instance) - writeFileSync(path, JSON.stringify(request)) - expect(validateOrcadManagedStopRequest(context(request), path)).toEqual(request) - for (const mismatch of [ - { ...request, version: '0.9.0' }, - { ...request, runtimeId: 'runtime-2' }, - { ...request, instance: { ...request.instance, pid: request.instance.pid + 1 } } - ]) { - writeFileSync(path, JSON.stringify(mismatch)) - expect(() => validateOrcadManagedStopRequest(context(request), path)).toThrow( - 'identity_mismatch' - ) - } - }) - - it('refuses a request once the instance lock names another holder', () => { - const { lock, request } = running() - const path = orcadManagedStopRequestPath(request.instance) - writeFileSync(path, JSON.stringify(request)) - writeFileSync(lock.path, JSON.stringify({ ...lock.record, nonce: 'successor' })) - expect(() => validateOrcadManagedStopRequest(context(request), path)).toThrow( - 'instance_lock_changed' - ) - }) -}) - -describe('completing a managed stop', () => { - it('writes the request, waits for proven exit, then persists a receipt', async () => { - const { request } = running() - const probes = ['alive', 'alive', 'missing'] as const - let index = 0 - const verdict = await completeOrcadManagedStop( - request, - options({ probeProcess: () => probes[Math.min(index++, probes.length - 1)] }) - ) - expect(verdict).toBe('exited') - expect(JSON.parse(readFileSync(orcadManagedStopRequestPath(request.instance), 'utf8'))).toEqual( - request - ) - expect(readOrcadCompletedStopReceipt(request)).toMatchObject({ - kind: 'orcad_managed_stop_completed', - exitedAt: '2026-10-01T00:00:00.000Z' - }) - }) - - it('treats a reused PID with a different start time as proof of exit', async () => { - const { request } = running() - expect(await completeOrcadManagedStop(request, options({ startedAtMs: () => 99_000 }))).toBe( - 'exited' - ) - }) - - it.each([ - ['an unanswerable probe', { probeProcess: () => 'unverifiable' as const }], - ['a reused PID with no readable start time', { startedAtMs: () => null, attempts: 1 }] - ])('never reports exit for %s', async (_name, overrides) => { - const { request } = running() - const verdict = await completeOrcadManagedStop(request, options(overrides)) - expect(verdict).not.toBe('exited') - expect(existsSync(orcadStopReceiptPath(request, 'completed'))).toBe(false) - }) - - it('reports a still-running instance as live after its attempts', async () => { - const { request } = running() - expect(await completeOrcadManagedStop(request, options())).toBe('live') - }) - - it('does not address a live process that no longer holds the lock it published', async () => { - const { lock, request } = running() - writeFileSync(lock.path, JSON.stringify({ ...lock.record, nonce: 'successor' })) - expect(await completeOrcadManagedStop(request, options())).toBe('unverifiable') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(false) - }) - - it('does not overwrite another transaction pending for the same instance', async () => { - const { request } = running() - const pending = { ...request, transactionId: '5a7e1f0c-3b2d-4e6f-9a8b-7c6d5e4f3a2b' } - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(pending)) - expect(await completeOrcadManagedStop(request, options())).toBe('unverifiable') - }) - - it('prints one completion line through the command', async () => { - const { request } = running() - const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - const completion = await runOrcadManagedStopCommand( - ['--complete-managed-stop', JSON.stringify(request)], - options({ probeProcess: () => 'missing' }) - ) - expect(completion).toMatchObject({ verdict: 'exited', receiptPersisted: true }) - expect( - OrcadManagedStopCompletionSchema.parse(JSON.parse(String(write.mock.calls[0]?.[0]))) - ).toEqual(completion) - await expect(runOrcadManagedStopCommand(['--complete-managed-stop'])).rejects.toThrow( - 'invalid_arguments' - ) - }) -}) - -describe('managed stops that retire the daemon', () => { - const outcome = (retirement: 'retired' | 'live' | 'unverifiable', liveSessions: number | null) => - vi.fn(async () => ({ retirement, liveSessions, reason: null })) - - it('does not touch the daemon when retirement was not asked for', async () => { - const { request } = running() - const retire = outcome('retired', 0) - await prepareOrcadManagedStop(request, retire) - expect(retire).not.toHaveBeenCalled() - expect(readOrcadDaemonRetirementRecord(request)).toBeNull() - }) - - it.each([ - ['retired', 0], - ['live', 2], - ['unverifiable', null] - ] as const)( - 'still completes the stop and records retirement %s on the receipt', - async (retirement, liveSessions) => { - const { request } = running() - const retireRequest = { ...request, retireIdleDaemon: true as const } - await prepareOrcadManagedStop(retireRequest, outcome(retirement, liveSessions)) - expect(readOrcadDaemonRetirementRecord(retireRequest)).toMatchObject({ - retirement, - liveSessions - }) - expect( - await completeOrcadManagedStop(retireRequest, options({ probeProcess: () => 'missing' })) - ).toBe('exited') - expect(readOrcadCompletedStopReceipt(retireRequest)).toMatchObject({ retirement }) - } - ) - - it('records unverifiable when the retirement attempt itself failed', async () => { - const { request } = running() - const retireRequest = { ...request, retireIdleDaemon: true as const } - await prepareOrcadManagedStop(retireRequest, async () => { - throw new Error('daemon socket closed') - }) - expect(readOrcadDaemonRetirementRecord(retireRequest)).toMatchObject({ - retirement: 'unverifiable' - }) - }) - - it('reports unverifiable when orcad exited without recording retirement', async () => { - const { request } = running() - const retireRequest = { ...request, retireIdleDaemon: true as const } - await completeOrcadManagedStop(retireRequest, options({ probeProcess: () => 'missing' })) - expect(readOrcadCompletedStopReceipt(retireRequest)).toMatchObject({ - retirement: 'unverifiable' - }) - }) - - it('omits retirement from the receipt of a plain managed stop', async () => { - const { request } = running() - await completeOrcadManagedStop(request, options({ probeProcess: () => 'missing' })) - expect(readOrcadCompletedStopReceipt(request)).not.toHaveProperty('retirement') - }) -}) diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 3360fdfd944b..21ad507c0164 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -14,7 +14,6 @@ const state = vi.hoisted(() => ({ controller: null as RuntimeMobileNotificationController | null, registry: null as DeviceRegistry | null, rpcStarted: false, - profileStartupErrors: new Array(), browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), send: vi.fn(async () => ({ ok: true, results: [] })) @@ -25,13 +24,7 @@ vi.mock('./orcad-app-paths', () => ({ resolveUserDataPath: () => state.root })) vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider })) -vi.mock('./orcad-instance-lock', () => ({ - acquireOrcadInstanceLock: () => ({ - path: join(state.root, 'orcad.lock'), - record: { pid: process.pid, startedAtMs: null, nonce: 'headless-instance' }, - release() {} - }) -})) +vi.mock('./orcad-instance-lock', () => ({ acquireOrcadInstanceLock: () => ({ release() {} }) })) vi.mock('./orcad-daemon-supervision', () => ({ startOrcadDaemon: async () => {}, stopOrcadDaemon: async () => {} @@ -44,26 +37,20 @@ vi.mock('../ipc/pty', () => ({ getSshPtyProvider: () => null })) vi.mock('./orcad-profile-state-startup', () => ({ - createOrcadProfileStateStartup: async () => { - const error = state.profileStartupErrors.shift() - if (error) { - throw error - } - return { - store: { - getSettings: () => ({}), - flushFinalOrThrowAsync: async () => {}, - freezeWritesAsync: async () => {} - }, - authority: { - backend: 'sqlite', - classification: 'neither', - authority_mode: 'sqlite-candidate', - runtime: 'orcad', - migrated: false - } + createOrcadProfileStateStartup: async () => ({ + store: { + getSettings: () => ({}), + flushFinalOrThrowAsync: async () => {}, + freezeWritesAsync: async () => {} + }, + authority: { + backend: 'sqlite', + classification: 'neither', + authority_mode: 'sqlite-candidate', + runtime: 'orcad', + migrated: false } - } + }) })) vi.mock('../orca-profiles/profile-index-store', () => ({ initOrcaProfilePaths() {}, @@ -87,7 +74,6 @@ vi.mock('../runtime/orca-runtime', () => ({ rehydrateClientHostedBrowserPages() {} async refreshRestoredOrchestrationAuthority() {} async reconcileLegacyWorkerTerminals() {} - async stopLegacyWorkerTerminalRecovery() {} setMobilePushRegistrar( registrar: Parameters[0] ) { @@ -136,7 +122,6 @@ vi.mock('../runtime/push/push-gateway-client', () => ({ afterEach(() => { rmSync(state.root, { recursive: true, force: true }) - state.profileStartupErrors.length = 0 vi.clearAllMocks() }) @@ -194,28 +179,9 @@ it('starts push after RPC identity is available and stops dispatch on shutdown', it('releases admission when host setup fails before a runtime exists', async () => { state.root = mkdtempSync(join(tmpdir(), 'orca-headless-setup-failure-')) - state.profileStartupErrors.push(new Error('profile startup failed')) + state.browserProvider.mockRejectedValueOnce(new Error('browser setup failed')) const { startOrcad } = await import('./orcad-entry') - await expect(startOrcad()).rejects.toThrow('profile startup failed') + await expect(startOrcad()).rejects.toThrow('browser setup failed') expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() }) - -it('serves RPC without waiting for browser discovery', async () => { - state.root = mkdtempSync(join(tmpdir(), 'orca-headless-browser-pending-')) - let finishDiscovery!: () => void - state.browserProvider.mockReturnValueOnce( - new Promise((resolve) => { - finishDiscovery = () => resolve(null) - }) - ) - const { startOrcad } = await import('./orcad-entry') - const host = await startOrcad({ noPairing: true, json: true }) - expect(host.managedStop).toMatchObject({ - runtimeId: 'headless-runtime', - instance: { pid: process.pid, nonce: 'headless-instance' } - }) - finishDiscovery() - await host.stop() - expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) -}) diff --git a/src/main/orcad/orcad-runtime-lifetime.test.ts b/src/main/orcad/orcad-runtime-lifetime.test.ts deleted file mode 100644 index 110e7d2876bf..000000000000 --- a/src/main/orcad/orcad-runtime-lifetime.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { OrcadRuntimeLifetime } from './orcad-runtime-lifetime' - -it('stops in reverse acquisition order and releases the instance lock last', async () => { - const events: string[] = [] - const lifetime = new OrcadRuntimeLifetime(() => { - events.push('lock') - }) - lifetime.add(() => { - events.push('browser') - }) - lifetime.add(async () => { - events.push('daemon') - }) - lifetime.add(() => { - events.push('rpc') - }) - const stopping = lifetime.stop() - expect(lifetime.stop()).toBe(stopping) - expect(() => lifetime.add(() => {})).toThrow('lifetime_stopping') - await stopping - expect(events).toEqual(['rpc', 'daemon', 'browser', 'lock']) - await lifetime.stop() - expect(events).toHaveLength(4) -}) - -it('awaits pending cleanup before stopping dependencies or releasing the lock', async () => { - const release = vi.fn() - const lifetime = new OrcadRuntimeLifetime(release) - const dependency = vi.fn() - let finish!: () => void - lifetime.add(dependency) - lifetime.add( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - const stopping = lifetime.stop() - await Promise.resolve() - expect(release).not.toHaveBeenCalled() - expect(dependency).not.toHaveBeenCalled() - finish() - await stopping - expect(dependency).toHaveBeenCalledOnce() - expect(release).toHaveBeenCalledOnce() -}) - -it('attempts every cleanup but retains the lock if any resource cannot stop', async () => { - const release = vi.fn() - const lifetime = new OrcadRuntimeLifetime(release) - const finalCleanup = vi.fn() - const first = new Error('rpc failed') - const second = new Error('browser failed') - lifetime.add(finalCleanup) - lifetime.add(() => { - throw second - }) - lifetime.add(async () => { - throw first - }) - const stopping = lifetime.stop() - await expect(stopping).rejects.toMatchObject({ errors: [first, second] }) - expect(finalCleanup).toHaveBeenCalledOnce() - expect(release).not.toHaveBeenCalled() - expect(lifetime.stop()).toBe(stopping) -}) - -it('tells later cleanups that an earlier one failed, and rethrows a single failure as-is', async () => { - const states: boolean[] = [] - const failure = new Error('profile writer still running') - const lifetime = new OrcadRuntimeLifetime() - lifetime.add(({ failed }) => { - states.push(failed) - }) - lifetime.add(() => { - throw failure - }) - await expect(lifetime.stop()).rejects.toBe(failure) - expect(states).toEqual([true]) -}) diff --git a/src/main/orcad/orcad-runtime-lifetime.ts b/src/main/orcad/orcad-runtime-lifetime.ts deleted file mode 100644 index e73de8293230..000000000000 --- a/src/main/orcad/orcad-runtime-lifetime.ts +++ /dev/null @@ -1,42 +0,0 @@ -export type OrcadRuntimeCleanup = (state: { failed: boolean }) => void | Promise - -/** - * Stops runtime resources in reverse acquisition order, then releases profile ownership. - * - * Every cleanup runs even after an earlier one fails, but a failed teardown never releases: - * a writer that may still be running cannot hand the data root to a second orcad. - */ -export class OrcadRuntimeLifetime { - private readonly cleanups: OrcadRuntimeCleanup[] = [] - private stopping?: Promise - - constructor(private readonly release: () => void = () => {}) {} - - add(cleanup: OrcadRuntimeCleanup): void { - if (this.stopping) { - throw new Error('orcad_runtime_lifetime_stopping') - } - this.cleanups.push(cleanup) - } - - stop(): Promise { - this.stopping ??= Promise.resolve().then(async () => { - const errors: unknown[] = [] - for (const cleanup of this.cleanups.splice(0).toReversed()) { - try { - await cleanup({ failed: errors.length > 0 }) - } catch (error) { - errors.push(error) - } - } - if (errors.length === 1) { - throw errors[0] - } - if (errors.length > 1) { - throw new AggregateError(errors, 'orcad_runtime_cleanup_failed') - } - this.release() - }) - return this.stopping - } -} diff --git a/src/main/orcad/orcad-stop-request-listener.test.ts b/src/main/orcad/orcad-stop-request-listener.test.ts deleted file mode 100644 index 2563543cb957..000000000000 --- a/src/main/orcad/orcad-stop-request-listener.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { ORCAD_STOP_REQUEST_FILENAME } from '../../shared/orcad-stop-request' -import { acquireOrcadInstanceLock } from './orcad-instance-lock' -import { orcadManagedStopRequestPath } from './orcad-managed-stop-request' -import { - installOrcadStopRequestListeners, - type OrcadStopRequestListener -} from './orcad-stop-request-listener' - -const roots: string[] = [] -const listeners: OrcadStopRequestListener[] = [] -afterEach(() => { - vi.restoreAllMocks() - for (const listener of listeners.splice(0)) { - listener.close() - } - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function directory(): string { - const root = mkdtempSync(join(tmpdir(), 'orcad-stop-listener-')) - roots.push(root) - return root -} - -function managedContext() { - const lock = acquireOrcadInstanceLock(directory(), { identity: () => 'uid-1000' }) - const { pid, startedAtMs, nonce } = lock.record - return { - version: '1.0.0', - runtimeId: 'runtime-1', - instance: { pid, startedAtMs, nonce, lockPath: lock.path } - } -} - -function listen( - onRequest: () => void, - options: Parameters[1] -) { - const listener = installOrcadStopRequestListeners(onRequest, { pollIntervalMs: 10, ...options }) - listeners.push(listener) - return listener -} - -describe('orcad stop-request listeners', () => { - it('consumes a slot request written before the listener started', () => { - const installRoot = directory() - writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') - const onRequest = vi.fn() - listen(onRequest, { installRoot }) - expect(onRequest).toHaveBeenCalledOnce() - expect(existsSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME))).toBe(false) - }) - - it('picks up a slot request written later, once', async () => { - const installRoot = directory() - const onRequest = vi.fn() - listen(onRequest, { installRoot }) - writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') - await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) - writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(onRequest).toHaveBeenCalledOnce() - }) - - it('stops after preparation even when preparation fails', async () => { - const managedStop = managedContext() - const onRequest = vi.fn() - const report = vi.spyOn(console, 'error').mockImplementation(() => {}) - listen(onRequest, { - installRoot: directory(), - managedStop, - beforeManagedStop: async () => { - throw new Error('daemon unreachable') - } - }) - writeFileSync( - orcadManagedStopRequestPath(managedStop.instance), - JSON.stringify({ - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - retireIdleDaemon: true, - ...managedStop - }) - ) - await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) - expect(report).toHaveBeenCalledWith( - '[orcad] managed stop preparation failed:', - expect.any(Error) - ) - }) - - it('stops on a valid managed request and keeps it as evidence', async () => { - const managedStop = managedContext() - const onRequest = vi.fn() - listen(onRequest, { installRoot: directory(), managedStop }) - const path = orcadManagedStopRequestPath(managedStop.instance) - writeFileSync( - path, - JSON.stringify({ - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - ...managedStop - }) - ) - await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) - expect(existsSync(path)).toBe(true) - }) - - it('ignores a managed request for another runtime and reports it once', async () => { - const managedStop = managedContext() - const onRequest = vi.fn() - const report = vi.spyOn(console, 'error').mockImplementation(() => {}) - listen(onRequest, { installRoot: directory(), managedStop }) - writeFileSync( - orcadManagedStopRequestPath(managedStop.instance), - JSON.stringify({ - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - ...managedStop, - runtimeId: 'runtime-2' - }) - ) - await vi.waitFor(() => expect(report).toHaveBeenCalled()) - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(onRequest).not.toHaveBeenCalled() - expect(report).toHaveBeenCalledOnce() - }) - - it('stops watching once closed', async () => { - const installRoot = directory() - const onRequest = vi.fn() - listen(onRequest, { installRoot }).close() - writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(onRequest).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/orcad/orcad-stop-request-listener.ts b/src/main/orcad/orcad-stop-request-listener.ts deleted file mode 100644 index 54ddeb9456d6..000000000000 --- a/src/main/orcad/orcad-stop-request-listener.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Watching for stop requests addressed to this orcad. - * - * Two listeners share one shutdown: the plain slot request (`.orcad-stop-request` beside - * `orcad.js`, consumed by unlinking it) and the instance-bound managed request in the data - * root, which is validated and kept as evidence until the completion command proves exit. - */ -import { unlinkSync, watch, type FSWatcher } from 'node:fs' -import { basename, dirname, join } from 'node:path' -import { - ORCAD_STOP_REQUEST_FILENAME, - type OrcadManagedStopContext, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { - orcadManagedStopRequestPath, - validateOrcadManagedStopRequest -} from './orcad-managed-stop-request' - -export type OrcadStopRequestListener = { close(): void } - -const DEFAULT_POLL_INTERVAL_MS = 1_000 - -function isMissingFile(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' -} - -/** `consume` returns true when the request should stop orcad; a missing file throws ENOENT. */ -function listenForRequest( - requestPath: string, - consume: (path: string) => boolean, - onRequest: () => void, - pollIntervalMs: number -): OrcadStopRequestListener { - let closed = false - let lastFailure: string | null = null - const check = (): void => { - if (closed) { - return - } - try { - if (!consume(requestPath)) { - return - } - } catch (error) { - if (!isMissingFile(error)) { - // Polling retries every interval; report each distinct refusal once. - const failure = error instanceof Error ? error.message : String(error) - if (failure !== lastFailure) { - lastFailure = failure - console.error(`[orcad] refused stop request at ${requestPath}:`, error) - } - } - return - } - closed = true - stop() - onRequest() - } - let watcher: FSWatcher | null = null - try { - watcher = watch(dirname(requestPath), (_event, changed) => { - if (!changed || basename(String(changed)) === basename(requestPath)) { - check() - } - }) - watcher.on('error', (error) => console.error('[orcad] stop-request watcher failed:', error)) - watcher.unref() - } catch (error) { - // The poll below still delivers requests; a watcher only makes them prompt. - console.error('[orcad] stop-request watcher could not start:', error) - } - const poll = setInterval(check, pollIntervalMs) - poll.unref() - const stop = (): void => { - watcher?.close() - clearInterval(poll) - } - // Covers a request written before this listener was installed. - check() - return { - close: () => { - closed = true - stop() - } - } -} - -export function installOrcadStopRequestListeners( - onRequest: () => void, - options: { - installRoot: string - managedStop?: OrcadManagedStopContext - /** Runs once for a validated managed request before the stop; it cannot prevent it. */ - beforeManagedStop?: (request: OrcadManagedStopRequest) => Promise - pollIntervalMs?: number - } -): OrcadStopRequestListener { - const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS - const listeners = [ - listenForRequest( - join(options.installRoot, ORCAD_STOP_REQUEST_FILENAME), - (path) => { - unlinkSync(path) - return true - }, - onRequest, - pollIntervalMs - ) - ] - const managedStop = options.managedStop - if (managedStop) { - const prepare = options.beforeManagedStop ?? (async () => {}) - listeners.push( - listenForRequest( - orcadManagedStopRequestPath(managedStop.instance), - (path) => { - const request = validateOrcadManagedStopRequest(managedStop, path) - close() - // Preparation is best effort: whatever it reports, the stop proceeds. - void prepare(request) - .catch((error: unknown) => - console.error('[orcad] managed stop preparation failed:', error) - ) - .finally(onRequest) - return false - }, - onRequest, - pollIntervalMs - ) - ) - } - const close = (): void => { - for (const listener of listeners) { - listener.close() - } - } - return { close } -} diff --git a/src/main/runtime/orca-runtime-automation-operations.ts b/src/main/runtime/orca-runtime-automation-operations.ts index 32d47c599519..5064944215ba 100644 --- a/src/main/runtime/orca-runtime-automation-operations.ts +++ b/src/main/runtime/orca-runtime-automation-operations.ts @@ -198,10 +198,6 @@ export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForeg return this.legacyWorkerRecovery.reconcile(options) } - stopLegacyWorkerTerminalRecovery(): Promise { - return this.legacyWorkerRecovery.stop() - } - protected updateLegacyWorkerTerminalRecoveryRetry( plan: LegacyWorkerTerminalRecoveryPlan, deferredDispatchIds: ReadonlySet, diff --git a/src/main/runtime/orca-runtime-headless-terminal-close.test.ts b/src/main/runtime/orca-runtime-headless-terminal-close.test.ts deleted file mode 100644 index 425a84c7bf9f..000000000000 --- a/src/main/runtime/orca-runtime-headless-terminal-close.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - PTY_ID, - TAB_ID, - WORKTREE_ID, - createHarness, - type CloseContinuityHarness -} from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' - -async function closeByPty(harness: CloseContinuityHarness): Promise { - const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find( - (candidate) => candidate.ptyId === PTY_ID - ) - if (!terminal) { - throw new Error('fixture pane has no terminal handle') - } - return harness.runtime.closeTerminal(terminal.handle) -} - -/** A PTY-backed tab with no paired-viewer surface: the path orcad serves to the CLI. */ -function headlessPtyTab(): CloseContinuityHarness { - const harness = createHarness({ registerPtyBacked: true }) - harness.syncFixtureTabWithoutLeaf() - harness.setVerifiedStopResult(true) - return harness -} - -describe('closing a terminal by PTY on a host without a renderer tab', () => { - it('stops the PTY without asking a missing renderer to close the tab', async () => { - const harness = headlessPtyTab() - harness.syncEmptyGraph() - - await expect(closeByPty(harness)).resolves.toMatchObject({ tabId: TAB_ID }) - - expect(harness.closeTerminalTab).not.toHaveBeenCalled() - expect(harness.stopAndWait).toHaveBeenCalledWith(PTY_ID, expect.anything()) - }) - - it('does not fail the close when the advisory renderer notification throws', async () => { - const harness = headlessPtyTab() - harness.syncEmptyGraph() - harness.closeTerminal.mockImplementation(() => { - throw new Error('renderer gone') - }) - - await expect(closeByPty(harness)).resolves.toMatchObject({ tabId: TAB_ID }) - }) -}) diff --git a/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts b/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts index ea954fbce8b9..a243d5ea0a6d 100644 --- a/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts +++ b/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts @@ -130,7 +130,7 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { throw error } - this.notifyRendererOfHeadlessTerminalClose(tabId) + this.notifier.closeTerminal?.(tabId) } const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) @@ -147,35 +147,17 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF throw error } const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) - this.notifyRendererOfHeadlessTerminalClose(tabId) + this.notifier?.closeTerminal(tabId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } - // Why the tabs guard: a headless host has no renderer tab to close through the notifier. - if ( - closesTab && - !surface && - pty.pty.tabId && - this.tabs.has(tabId) && - this.notifier?.closeTerminalTab - ) { + if (closesTab && !surface && pty.pty.tabId && this.notifier?.closeTerminalTab) { const ptyIdsToKill = this.getPtyIdsForExplicitTabClose(pty.pty.worktreeId, tabId) - let tabClosed = true - try { - await this.notifier.closeTerminalTab(tabId, { localPtyTeardownOwnedExternally: true }) - } catch (error) { - // The tab went away concurrently; fall through and close the PTY alone. - if (!(error instanceof Error) || error.message !== 'tab_not_found') { - throw error - } - tabClosed = false - } - if (tabClosed) { - const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) - return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) - } + await this.notifier.closeTerminalTab(tabId, { localPtyTeardownOwnedExternally: true }) + const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) + return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) if (!closesTab) { @@ -194,10 +176,10 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { throw error } - this.notifyRendererOfHeadlessTerminalClose(tabId) + this.notifier?.closeTerminal(tabId) } } else { - this.notifyRendererOfHeadlessTerminalClose(tabId) + this.notifier?.closeTerminal(tabId) } return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } diff --git a/src/main/runtime/runtime-legacy-worker-recovery-lifetime.test.ts b/src/main/runtime/runtime-legacy-worker-recovery-lifetime.test.ts deleted file mode 100644 index 9a950a47354f..000000000000 --- a/src/main/runtime/runtime-legacy-worker-recovery-lifetime.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { RuntimeLegacyWorkerTerminalRecoveryController } from './runtime-legacy-worker-terminal-recovery-controller' -import type { LegacyWorkerRecoveryPorts } from './runtime-legacy-worker-terminal-recovery-types' -import type { - LegacyWorkerTerminalRecoveryCandidate, - LegacyWorkerTerminalRecoveryPlan -} from './orchestration/orchestration-legacy-worker-terminal-recovery' - -function candidate(ptyId: string, dispatchId: string): LegacyWorkerTerminalRecoveryCandidate { - return { - dispatchId, - dispatchStatus: 'dispatched', - contractVersion: 1, - taskId: 'task', - worktreeId: 'worktree', - terminalHandle: 'handle', - paneKey: 'tab:leaf', - tabId: 'tab', - leafId: 'leaf', - processIncarnation: `${ptyId}:incarnation`, - ptyId, - incarnationId: 'incarnation' - } -} - -function setup() { - const ports = { - preparePlan: vi.fn((): LegacyWorkerTerminalRecoveryPlan => ({ - candidates: [], - ambiguousDispatchIds: [] - })), - resolveWorkspace: vi.fn(async () => { - throw new Error('unused') - }), - refreshInventory: vi.fn(async () => null), - runMutation: (_worktreeId: string, operation: () => Promise) => operation(), - getActivation: () => ({}), - hasExactPersistedSurface: () => false, - hasExactSurface: () => false, - adopt: vi.fn(async () => {}), - getRendererEpoch: () => 0, - reveal: vi.fn(async () => null), - onPtyExit: vi.fn(), - persist: vi.fn(async (): Promise> => new Set()), - rollback: vi.fn(), - reconcileMissing: () => false, - notifyResolution: vi.fn(), - canRecoverPersistentLocalPtys: () => true, - reconcileRequestedReleases: vi.fn(async (): Promise => undefined), - reconcile: vi.fn(async () => ({ - adoptedDispatchIds: [], - exitedDispatchIds: [], - deferredDispatchIds: [] - })), - updateRetry: vi.fn() - } satisfies LegacyWorkerRecoveryPorts - const controller = new RuntimeLegacyWorkerTerminalRecoveryController(ports) - return { controller, ports } -} -afterEach(() => vi.useRealTimers()) - -it('cancels both local and SSH retry timers and refuses later recovery work', async () => { - vi.useFakeTimers() - const { controller, ports } = setup() - const plan: LegacyWorkerTerminalRecoveryPlan = { - ambiguousDispatchIds: [], - candidates: [ - candidate('local-pty', 'local-dispatch'), - candidate('ssh:host@@remote-pty', 'remote-dispatch') - ] - } - const deferred = new Set(['local-dispatch', 'remote-dispatch']) - controller.updateRetry(plan, deferred, {}) - controller.updateRetry(plan, deferred, { connectionId: 'host' }) - expect(vi.getTimerCount()).toBe(2) - await controller.stop() - controller.updateRetry(plan, deferred, {}) - await vi.advanceTimersByTimeAsync(60_000) - expect(vi.getTimerCount()).toBe(0) - expect(ports.reconcile).not.toHaveBeenCalled() - await expect(controller.reconcile()).rejects.toThrow('recovery_stopped') -}) - -it('drains an active persistence pass but refuses a queued pass after shutdown', async () => { - const { controller, ports } = setup() - let finish!: (value: Set) => void - ports.persist.mockImplementationOnce( - () => - new Promise>((resolve) => { - finish = resolve - }) - ) - const first = controller.reconcile() - await vi.waitFor(() => expect(ports.persist).toHaveBeenCalledOnce()) - const queued = expect(controller.reconcile()).rejects.toThrow('recovery_stopped') - const settled = vi.fn() - const stopping = controller.stop().then(settled) - await Promise.resolve() - expect(settled).not.toHaveBeenCalled() - finish(new Set()) - await Promise.all([first, queued, stopping]) - expect(ports.preparePlan).toHaveBeenCalledOnce() - expect(ports.persist).toHaveBeenCalledOnce() -}) - -it('holds shutdown until requested-release reconciliation finishes', async () => { - const { controller, ports } = setup() - let finish!: () => void - ports.reconcileRequestedReleases.mockImplementationOnce( - () => - new Promise((resolve) => { - finish = () => resolve(undefined) - }) - ) - const recovery = controller.reconcile() - await vi.waitFor(() => expect(ports.reconcileRequestedReleases).toHaveBeenCalledOnce()) - const settled = vi.fn() - const stopping = controller.stop().then(settled) - await Promise.resolve() - expect(settled).not.toHaveBeenCalled() - finish() - await Promise.all([recovery, stopping]) - expect(settled).toHaveBeenCalledOnce() -}) diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts index 44a535c81274..59e29a1858d7 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts @@ -32,31 +32,12 @@ export class RuntimeLegacyWorkerTerminalRecoveryController { private readonly retries = new Map() private readonly receiptEpochByPane = new Map() private readonly recoveredPtys = new Set() - private readonly backgroundWork = new Set>() - private stopped = false constructor(private readonly ports: LegacyWorkerRecoveryPorts) {} - /** Cancels retries and refuses new passes; resolves once running and released work drained. */ - async stop(): Promise { - this.stopped = true - this.cancelAllRetries() - await this.queue - await Promise.all(this.backgroundWork) - } - - /** Work a pass starts without awaiting; shutdown still waits for it. */ - trackBackgroundWork(work: Promise): void { - this.backgroundWork.add(work) - void work.finally(() => this.backgroundWork.delete(work)) - } - reconcile( options: LegacyWorkerRecoveryOptions = {} ): Promise { - if (this.stopped) { - return Promise.reject(new Error('worker_terminal_recovery_stopped')) - } let resolveResult!: (result: LegacyWorkerTerminalRecoveryResult) => void let rejectResult!: (error: unknown) => void const result = new Promise((resolve, reject) => { @@ -65,9 +46,6 @@ export class RuntimeLegacyWorkerTerminalRecoveryController { }) const run = this.queue.then(async () => { try { - if (this.stopped) { - throw new Error('worker_terminal_recovery_stopped') - } resolveResult(await runLegacyWorkerTerminalRecovery(this, this.ports, options)) } catch (error) { rejectResult(error) @@ -99,9 +77,6 @@ export class RuntimeLegacyWorkerTerminalRecoveryController { deferredDispatchIds: ReadonlySet, options: LegacyWorkerRecoveryOptions ): void { - if (this.stopped) { - return - } const scopeKey = options.connectionId ? `ssh:${options.connectionId}` : 'local' const hasDeferredWorker = plan.candidates.some((candidate) => { const sshPty = parseAppSshPtyId(candidate.ptyId) @@ -150,7 +125,7 @@ export class RuntimeLegacyWorkerTerminalRecoveryController { } private armRetry(scopeKey: string, retry: RecoveryRetry): void { - if (this.stopped || retry.timer) { + if (retry.timer) { return } const delayMs = Math.min(1_000 * 2 ** retry.attempt, 30_000) diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts index adbb89512d21..6bbe3b2ed2f4 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts @@ -110,10 +110,8 @@ export async function runLegacyWorkerTerminalRecovery( } ports.updateRetry(plan, deferredDispatchIds, options) // Why: releases may only finish after the owning provider's terminals are rediscovered. - controller.trackBackgroundWork( - ports.reconcileRequestedReleases().catch((error) => { - console.warn('[orchestration] worker terminal release reconciliation failed', { error }) - }) - ) + void ports.reconcileRequestedReleases().catch((error) => { + console.warn('[orchestration] worker terminal release reconciliation failed', { error }) + }) return result } diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index eaefd4d367d0..c761ccf0409f 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -55,9 +55,8 @@ describe('headless PTY registry hydration ordering', () => { it('starts the orcad hook owner after Store hydration and before daemon PTY recovery', () => { const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') - // Each runtime resource registers its own cleanup; the hook owner's must precede its start. - const cleanup = source.indexOf('registerCleanup(') - const hookStop = source.indexOf('registerCleanup(() => agentHookServer.stop())', cleanup) + const cleanup = source.indexOf('registerCleanup(async () => {') + const hookStop = source.indexOf('agentHookServer.stop()', cleanup) const store = source.indexOf('createOrcadProfileStateStartup(runtimeUserDataPath)') const hookStart = source.indexOf('await agentHookServer.start(', store) const daemon = source.indexOf('await startOrcadDaemon()', hookStart) @@ -65,7 +64,7 @@ describe('headless PTY registry hydration ordering', () => { const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', hookEnv) expect(cleanup).toBeGreaterThanOrEqual(0) - expect(hookStop).toBeGreaterThanOrEqual(cleanup) + expect(hookStop).toBeGreaterThan(cleanup) expect(store).toBeGreaterThan(hookStop) expect(hookStart).toBeGreaterThan(store) expect(daemon).toBeGreaterThan(hookStart) diff --git a/src/shared/orcad-stop-request.ts b/src/shared/orcad-stop-request.ts deleted file mode 100644 index ee3bfaf474c6..000000000000 --- a/src/shared/orcad-stop-request.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Stop requests for a running orcad, as files beside the process they address. - * - * Why files and not signals: a PID can be reused once its process exits, so a signal sent - * from a stale record can stop an unrelated process. A file in the slot or data root reaches - * only the orcad that watches it. - */ -import { z } from 'zod' - -/** Plain request in the slot directory: the same graceful stop as SIGTERM. */ -export const ORCAD_STOP_REQUEST_FILENAME = '.orcad-stop-request' -/** Prefix of an instance-bound request in the data root; older listeners never match it. */ -export const ORCAD_MANAGED_STOP_REQUEST_PREFIX = '.orcad-managed-stop-request' -export const ORCAD_MANAGED_STOP_REQUEST_MAX_BYTES = 32 * 1024 -export const ORCAD_STOP_RECEIPTS_DIRNAME = 'orcad-stop-receipts' -export const ORCAD_COMPLETE_MANAGED_STOP_FLAG = '--complete-managed-stop' - -/** One orcad process: its PID, start time, and the instance lock record it published. */ -export const OrcadManagedStopInstanceSchema = z.strictObject({ - pid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), - startedAtMs: z.number().finite().nonnegative().nullable(), - nonce: z.string().min(1).max(255), - lockPath: z.string().min(1).max(4096) -}) - -export const OrcadManagedStopRequestSchema = z.strictObject({ - schemaVersion: z.literal(1), - transactionId: z.uuid(), - version: z.string().min(1).max(255), - runtimeId: z.string().min(1).max(255), - instance: OrcadManagedStopInstanceSchema, - /** Best effort: also retire the terminal daemon if it is provably idle. Never blocks the stop. */ - retireIdleDaemon: z.literal(true).optional() -}) - -export type OrcadManagedStopInstance = z.infer -export type OrcadManagedStopRequest = z.infer -/** What a stopping orcad must match before a managed request may stop it. */ -export type OrcadManagedStopContext = Omit< - OrcadManagedStopRequest, - 'schemaVersion' | 'transactionId' -> - -/** The execution host's verdict; `exited` only on proof, never on silence. */ -export const OrcadManagedStopVerdictSchema = z.enum(['live', 'unverifiable', 'exited']) - -export const OrcadManagedStopCompletionSchema = OrcadManagedStopRequestSchema.extend({ - kind: z.literal('orcad_managed_stop_completion'), - verdict: OrcadManagedStopVerdictSchema, - receiptPersisted: z.boolean() -}) - -/** `retired` only when the daemon accepted; a busy daemon stays up and keeps its terminals. */ -export const OrcadDaemonRetirementVerdictSchema = z.enum(['retired', 'live', 'unverifiable']) - -/** What the stopping orcad observed about its daemon, written before it exits. */ -export const OrcadDaemonRetirementRecordSchema = z.strictObject({ - schemaVersion: z.literal(1), - kind: z.literal('orcad_managed_stop_retirement'), - request: OrcadManagedStopRequestSchema, - retirement: OrcadDaemonRetirementVerdictSchema, - liveSessions: z.number().int().nonnegative().nullable(), - reason: z.string().max(4096).nullable() -}) - -export const OrcadCompletedStopReceiptSchema = z.strictObject({ - schemaVersion: z.literal(1), - kind: z.literal('orcad_managed_stop_completed'), - request: OrcadManagedStopRequestSchema, - exitedAt: z.iso.datetime({ offset: true }), - /** Present only when the request asked to retire the daemon. */ - retirement: OrcadDaemonRetirementVerdictSchema.optional() -}) - -export type OrcadManagedStopVerdict = z.infer -export type OrcadManagedStopCompletion = z.infer -export type OrcadCompletedStopReceipt = z.infer -export type OrcadDaemonRetirementVerdict = z.infer -export type OrcadDaemonRetirementRecord = z.infer From dbbfc3da45b0eb2d9cac9b1708e891c3601fad3b Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:02 -0700 Subject: [PATCH 07/26] Revert "feat(ssh): crash-safe orcad activation, rollback and recovery (#16741 T6-2) (#24423)" This reverts commit 1a9ac0e955b87af454a225d9615a56c1bb753603. --- .../orcad-daemon-protocol-crossing.test.mjs | 100 ------ .../orcad-activation-crash-recovery.test.ts | 232 ------------- .../ssh/orcad-activation-host-test-harness.ts | 228 ------------- src/main/ssh/orcad-activation-lock.ts | 154 --------- src/main/ssh/orcad-activation-recovery.ts | 128 -------- .../orcad-activation-transaction-schema.ts | 79 ----- .../ssh/orcad-activation-transaction-store.ts | 55 ---- ...rcad-activation-transaction-transitions.ts | 102 ------ .../ssh/orcad-activation-transaction.test.ts | 228 ------------- src/main/ssh/orcad-activation-transaction.ts | 225 ------------- .../ssh/orcad-daemon-protocol-crossing.ts | 56 ---- src/main/ssh/orcad-incumbent-recovery.ts | 153 --------- .../ssh/orcad-initial-activation-admission.ts | 92 ------ src/main/ssh/orcad-installed-activation.ts | 305 ------------------ src/main/ssh/orcad-recovery-slot.ts | 193 ----------- src/main/ssh/orcad-remote-deploy-stop.ts | 68 ++++ src/main/ssh/orcad-remote-deploy.test.ts | 24 +- src/main/ssh/orcad-remote-deploy.ts | 253 +++++++++++++-- src/main/ssh/orcad-remote-rollback.test.ts | 128 +++----- src/main/ssh/orcad-remote-rollback.ts | 212 +++++++++--- src/main/ssh/orcad-rollback-transition.ts | 281 ---------------- .../ssh/orcad-state-snapshot-shell.test.ts | 88 ----- src/main/ssh/orcad-state-snapshot.test.ts | 20 +- src/main/ssh/orcad-state-snapshot.ts | 60 +--- src/main/ssh/orcad-update-plan.test.ts | 115 +------ src/main/ssh/orcad-update-plan.ts | 70 +--- src/main/ssh/ssh-relay-install-lock.test.ts | 27 +- src/main/ssh/ssh-relay-install-lock.ts | 30 +- src/shared/runtime-bootstrap.ts | 2 +- 29 files changed, 546 insertions(+), 3162 deletions(-) delete mode 100644 config/scripts/orcad-daemon-protocol-crossing.test.mjs delete mode 100644 src/main/ssh/orcad-activation-crash-recovery.test.ts delete mode 100644 src/main/ssh/orcad-activation-host-test-harness.ts delete mode 100644 src/main/ssh/orcad-activation-lock.ts delete mode 100644 src/main/ssh/orcad-activation-recovery.ts delete mode 100644 src/main/ssh/orcad-activation-transaction-schema.ts delete mode 100644 src/main/ssh/orcad-activation-transaction-store.ts delete mode 100644 src/main/ssh/orcad-activation-transaction-transitions.ts delete mode 100644 src/main/ssh/orcad-activation-transaction.test.ts delete mode 100644 src/main/ssh/orcad-activation-transaction.ts delete mode 100644 src/main/ssh/orcad-daemon-protocol-crossing.ts delete mode 100644 src/main/ssh/orcad-incumbent-recovery.ts delete mode 100644 src/main/ssh/orcad-initial-activation-admission.ts delete mode 100644 src/main/ssh/orcad-installed-activation.ts delete mode 100644 src/main/ssh/orcad-recovery-slot.ts create mode 100644 src/main/ssh/orcad-remote-deploy-stop.ts delete mode 100644 src/main/ssh/orcad-rollback-transition.ts delete mode 100644 src/main/ssh/orcad-state-snapshot-shell.test.ts diff --git a/config/scripts/orcad-daemon-protocol-crossing.test.mjs b/config/scripts/orcad-daemon-protocol-crossing.test.mjs deleted file mode 100644 index 131c1d54c57e..000000000000 --- a/config/scripts/orcad-daemon-protocol-crossing.test.mjs +++ /dev/null @@ -1,100 +0,0 @@ -// D7: orcad's update and rollback planning must agree with the CI protocol-crossing facts. -import { readFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { - DAEMON_PROTOCOL_SOURCE_PATH, - canAttach, - parseDaemonProtocolFacts -} from './daemon-protocol-facts.mjs' -import { CURRENT_ORCAD_DAEMON_PROTOCOL } from '../../src/main/ssh/orcad-daemon-protocol-crossing' -import { assessOrcadRollback, planOrcadUpdate } from '../../src/main/ssh/orcad-update-plan' - -const projectDir = resolve(import.meta.dirname, '../..') -const current = parseDaemonProtocolFacts( - readFileSync(join(projectDir, DAEMON_PROTOCOL_SOURCE_PATH), 'utf8') -) -// The release before the newest protocol bump: it speaks one version lower and cannot list ours. -const older = { - protocolVersion: current.protocolVersion - 1, - previousProtocolVersions: current.previousProtocolVersions.filter( - (version) => version < current.protocolVersion - 1 - ) -} -const record = { - schemaVersion: 1, - active: '0.3.0+new', - previous: '0.2.0+old', - activatedAt: '2026-01-01T00:00:00.000Z', - snapshot: { - dirName: 'pre-0.3.0+new-1', - takenBeforeVersion: '0.3.0+new', - readableByVersion: '0.2.0+old', - takenAt: '2026-01-01T00:00:00.000Z' - } -} -const live = (daemonProtocolVersion) => ({ - liveSessions: 2, - startedSinceActivation: 0, - daemonProtocolVersion -}) - -function rollback(target, daemonProtocolVersion) { - return assessOrcadRollback({ - record, - snapshotPresent: true, - census: live(daemonProtocolVersion), - targetDaemonProtocol: target, - stateWritesSinceActivation: false - }) -} - -describe('orcad daemon protocol crossing', () => { - it('deploys exactly the protocol the working tree declares', () => { - expect({ - protocolVersion: CURRENT_ORCAD_DAEMON_PROTOCOL.protocolVersion, - previousProtocolVersions: [...CURRENT_ORCAD_DAEMON_PROTOCOL.previousProtocolVersions] - }).toEqual(current) - }) - - it('keeps terminals on rollback only when the old build lists the new protocol', () => { - expect(canAttach(older, current)).toBe(false) - expect(rollback(older, current.protocolVersion)).toMatchObject({ - safety: 'unsafe', - code: 'orcad_rollback_strands_live_terminals' - }) - // A daemon preserved from before the activation still speaks the old build's protocol. - expect(canAttach(older, older)).toBe(true) - expect(rollback(older, older.protocolVersion)).toMatchObject({ safety: 'clean' }) - const listing = { ...older, previousProtocolVersions: [...older.previousProtocolVersions] } - listing.previousProtocolVersions.push(current.protocolVersion + 1) - expect(canAttach(listing, { ...current, protocolVersion: current.protocolVersion + 1 })).toBe( - true - ) - expect(rollback(listing, current.protocolVersion + 1)).toMatchObject({ safety: 'clean' }) - }) - - it('updates over live terminals only when the candidate can attach their daemon', () => { - const plan = (daemonProtocolVersion) => - planOrcadUpdate({ - record, - candidateVersion: '0.4.0+next', - census: live(daemonProtocolVersion), - candidateDaemonProtocol: current, - force: true - }) - expect(canAttach(current, older)).toBe(true) - expect(plan(older.protocolVersion)).toMatchObject({ - action: 'proceed', - preservesLiveDaemon: true - }) - const dropped = current.protocolVersion + 1 - expect(canAttach(current, { protocolVersion: dropped, previousProtocolVersions: [] })).toBe( - false - ) - expect(plan(dropped)).toMatchObject({ - action: 'defer', - code: 'orcad_update_strands_live_terminals' - }) - }) -}) diff --git a/src/main/ssh/orcad-activation-crash-recovery.test.ts b/src/main/ssh/orcad-activation-crash-recovery.test.ts deleted file mode 100644 index ddb7fb39ce66..000000000000 --- a/src/main/ssh/orcad-activation-crash-recovery.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as DeployHelpers from './ssh-relay-deploy-helpers' -import type * as RecordFile from './orcad-remote-record-file' -import type * as InstallLock from './ssh-relay-install-lock' -import type * as VersionedInstall from './ssh-relay-versioned-install' - -vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ - ...(await importOriginal()), - execCommand: vi.fn() -})) -vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) -vi.mock('./ssh-relay-install-lock', async (importOriginal) => ({ - ...(await importOriginal()), - acquireInstallLock: vi.fn() -})) -vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ - ...(await importOriginal()), - writeAtomicOrcadRemoteRecord: vi.fn() -})) -vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ - ...(await importOriginal()), - readLocalFullVersion: () => '0.2.0+bb01' -})) -vi.mock('./orcad-remote-install', () => ({ installOrcadBundle: vi.fn() })) -vi.mock('./orcad-remote-preflight', () => ({ preflightInstalledOrcad: vi.fn() })) -vi.mock('./orcad-local-build-hash', () => ({ - computeLocalOrcadBuildHash: () => 'abc123def4567890' -})) - -import { execCommand } from './ssh-relay-deploy-helpers' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' -import { deployOrcad } from './orcad-remote-deploy' -import { rollbackOrcad } from './orcad-remote-rollback' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' -import { - BUILD_HASH, - FakeOrcadHost, - NEW, - OLD, - type CrashMode -} from './orcad-activation-host-test-harness' - -let host = new FakeOrcadHost() - -const slot = { - conn: {} as SshConnection, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/u', - nodePath: '/usr/bin/node', - userDataDir: '/home/u/.orca', - bindHost: '127.0.0.1', - port: 7777, - readinessTimeoutMs: 50, - sleep: async () => {}, - now: () => new Date('2026-02-02T00:00:00.000Z') -} -const census = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } - -const deploy = (): Promise => - deployOrcad({ ...slot, localOrcadDir: '/local/out/orcad', target: 'linux-x64-glibc', census }) -const rollback = (): Promise => - rollbackOrcad({ - ...slot, - record: FakeOrcadHost.newRecord(), - census, - targetBuildHash: BUILD_HASH, - targetDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] }, - terminalsStartedSince: async () => 0 - }) - -beforeEach(() => { - vi.clearAllMocks() - vi.mocked(execCommand).mockImplementation(async (_conn, command) => host.exec(command)) - vi.mocked(acquireInstallLock).mockImplementation(async () => host.acquireFence()) - vi.mocked(writeAtomicOrcadRemoteRecord).mockImplementation(async (_target, path, contents) => - host.write(path, contents) - ) -}) - -/** The invariant: the host serves exactly the slot its record names, on state that slot reads. */ -function expectExactlyTheRecordedSlot(): void { - const active = host.activeVersion() - expect([...host.alive]).toEqual(active ? [active] : []) - expect(host.isReadableBy(active)).toBe(true) - expect(host.journal).toBeNull() - expect(host.fence).toBe(false) -} - -/** Unconfirmed termination never deletes a slot directory or a snapshot. */ -function expectNoSlotOrSnapshotRemoved(): void { - const removals = host.commands.filter((command) => /\brm -r?f\b/u.test(command)) - for (const command of removals) { - expect(command).not.toMatch(/rm -r?f '[^']*\/orcad-\d/u) - expect(command).not.toMatch(/rm -r?f '[^']*orcad-state-snapshots/u) - } -} - -function countMutations( - scenario: () => FakeOrcadHost, - run: () => Promise -): Promise { - host = scenario() - return run().then(() => host.mutations) -} - -const scenarios: [string, () => FakeOrcadHost, () => Promise][] = [ - ['an update over an incumbent', FakeOrcadHost.deployedOld, deploy], - ['a first activation', () => new FakeOrcadHost(), deploy], - ['a rollback', FakeOrcadHost.activatedNew, rollback] -] - -describe.each(scenarios)('%s interrupted at every mutation', (_name, scenario, run) => { - it('completes cleanly when nothing interrupts it', async () => { - host = scenario() - await run() - expectExactlyTheRecordedSlot() - expect(host.activeVersion()).toBe(run === rollback ? OLD : NEW) - }) - - it.each(['before', 'after'])( - 'recovers to exactly one recorded slot when the host answer is lost %s applying it', - async (mode) => { - const total = await countMutations(scenario, run) - expect(total).toBeGreaterThan(3) - for (let crashAt = 1; crashAt <= total; crashAt += 1) { - host = scenario() - host.crashAt = crashAt - host.crashMode = mode - await run().catch(() => undefined) - host.crashAt = null - const result = await recoverInterruptedOrcadActivation({ - ...slot, - terminalsStartedSince: async () => 0 - }) - expect(['recovered', 'none'], `mutation ${crashAt}`).toContain(result.outcome) - expectExactlyTheRecordedSlot() - expectNoSlotOrSnapshotRemoved() - } - } - ) -}) - -describe('recovery refusals keep the fence', () => { - async function interruptedAfterCandidateLaunch(): Promise { - host = FakeOrcadHost.deployedOld() - const total = await countMutations(FakeOrcadHost.deployedOld, deploy) - host = FakeOrcadHost.deployedOld() - // The last three mutations are: candidate-ready journal, record, fence release. - host.crashAt = total - 2 - host.crashMode = 'before' - await deploy().catch(() => undefined) - host.crashAt = null - expect(host.alive.has(NEW)).toBe(true) - } - - it('does not restore state over terminals started since the interrupted change', async () => { - await interruptedAfterCandidateLaunch() - const result = await recoverInterruptedOrcadActivation({ - ...slot, - terminalsStartedSince: async () => 2 - }) - expect(result).toMatchObject({ - outcome: 'refused', - verdict: 'live', - code: 'orcad_recovery_orphans_live_terminals' - }) - expect(host.fence).toBe(true) - expect(host.journal).not.toBeNull() - expect(host.alive.size).toBe(0) - }) - - it.each([undefined, null])('keeps changed state when the fresh census is %s', async (started) => { - await interruptedAfterCandidateLaunch() - const result = await recoverInterruptedOrcadActivation({ - ...slot, - ...(started === undefined ? {} : { terminalsStartedSince: async () => started }) - }) - expect(result).toMatchObject({ outcome: 'refused', code: 'orcad_recovery_census_required' }) - expect(host.journal).not.toBeNull() - }) - - it('never treats an unverifiable incumbent as exited', async () => { - host = FakeOrcadHost.deployedOld() - host.crashAt = 3 - await deploy().catch(() => undefined) - host.crashAt = null - host.pidFiles.delete(OLD) - host.alive.delete(OLD) - const result = await recoverInterruptedOrcadActivation({ ...slot }) - expect(result).toMatchObject({ outcome: 'refused', verdict: 'unverifiable' }) - expect(host.fence).toBe(true) - expect(host.alive.size).toBe(0) - }) - - it('reports a fresh fence as pending instead of taking it over', async () => { - host = FakeOrcadHost.deployedOld() - host.crashAt = 3 - await deploy().catch(() => undefined) - host.crashAt = null - const journal = host.journal - expect(journal).not.toBeNull() - const { RemoteInstallLockBusyError } = await vi.importActual( - './ssh-relay-install-lock' - ) - vi.mocked(acquireInstallLock).mockRejectedValueOnce(new RemoteInstallLockBusyError('/l', 0)) - expect(await recoverInterruptedOrcadActivation({ ...slot })).toMatchObject({ - outcome: 'pending' - }) - expect(host.journal).toBe(journal) - }) - - it('keeps a journal this client cannot read', async () => { - host = FakeOrcadHost.deployedOld() - host.journal = JSON.stringify({ schemaVersion: 1, operation: 'decommission' }) - host.fence = true - expect(await recoverInterruptedOrcadActivation({ ...slot })).toMatchObject({ - outcome: 'refused', - code: 'orcad_recovery_unverifiable' - }) - expect(host.fence).toBe(true) - }) - - it('drops a fence whose release was cut short after the journal went', async () => { - host = FakeOrcadHost.deployedOld() - host.fence = true - expect(await recoverInterruptedOrcadActivation({ ...slot })).toEqual({ outcome: 'none' }) - expect(host.fence).toBe(false) - }) -}) diff --git a/src/main/ssh/orcad-activation-host-test-harness.ts b/src/main/ssh/orcad-activation-host-test-harness.ts deleted file mode 100644 index 1ac0fd05edcc..000000000000 --- a/src/main/ssh/orcad-activation-host-test-harness.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * A stateful fake orcad host for crash-recovery tests: slot processes, PID files, the shared - * profile state, snapshots, the activation record, the journal and its fence. It can drop the - * connection at any numbered mutation, before or after the host applied it. - */ -import { - emptyOrcadActivationRecord, - parseOrcadActivationRecord, - withActivatedVersion, - type OrcadActivationRecord -} from './orcad-activation-record' - -export const OLD = '0.1.0+aa01' -export const NEW = '0.2.0+bb01' -export const BUILD_HASH = 'abc123def4567890' -/** State the incoming build migrates on load; the outgoing build cannot read it. */ -const MIGRATION = '|migrated-by-new' - -export type CrashMode = 'before' | 'after' - -export class FakeOrcadHost { - record: string | null = null - journal: string | null = null - fence = false - readonly alive = new Set() - readonly pidFiles = new Set() - data = 'profiles-v1' - readonly snapshots = new Map() - readonly commands: string[] = [] - mutations = 0 - crashAt: number | null = null - crashMode: CrashMode = 'before' - - static deployedOld(): FakeOrcadHost { - const host = new FakeOrcadHost() - host.record = JSON.stringify( - withActivatedVersion(emptyOrcadActivationRecord(), OLD, null, new Date(0)) - ) - host.alive.add(OLD) - host.pidFiles.add(OLD) - return host - } - - static activatedNew(): FakeOrcadHost { - const host = FakeOrcadHost.deployedOld() - host.alive.clear() - host.pidFiles.add(NEW) - host.alive.add(NEW) - host.snapshots.set('pre-0.2.0+bb01-1000', host.data) - host.data += MIGRATION - host.record = JSON.stringify(FakeOrcadHost.newRecord()) - return host - } - - static newRecord(): OrcadActivationRecord { - return withActivatedVersion( - { - ...emptyOrcadActivationRecord(), - active: OLD, - activatedAt: new Date(0).toISOString() - }, - NEW, - { - dirName: 'pre-0.2.0+bb01-1000', - takenBeforeVersion: NEW, - readableByVersion: OLD, - takenAt: new Date(1000).toISOString() - }, - new Date(1000) - ) - } - - activeVersion(): string | null { - const parsed = parseOrcadActivationRecord(this.record) - return parsed.state === 'ok' ? parsed.record.active : null - } - - /** Every state the old build can read: the pre-migration profile. */ - isReadableBy(version: string | null): boolean { - return version !== OLD || !this.data.includes(MIGRATION) - } - - private mutate(apply: () => T): T { - this.mutations += 1 - if (this.crashAt !== this.mutations) { - return apply() - } - if (this.crashMode === 'after') { - apply() - } - throw Object.assign(new Error(`connection lost at mutation ${this.mutations}`), { - sshChannelCloseConfirmed: false - }) - } - - write(path: string, contents: string): void { - this.mutate(() => { - if (path.endsWith('transaction.json')) { - this.journal = contents - } else if (path.endsWith('orcad-active.json')) { - this.record = contents - } - }) - } - - acquireFence(): void { - this.fence = true - } - - exec(command: string): string { - this.commands.push(command) - const version = /\/orcad-(\d+\.\d+\.\d+\+[0-9a-f]+)/u.exec(command)?.[1] ?? null - const snapshot = /orcad-state-snapshots\/([A-Za-z0-9][A-Za-z0-9.+-]*)/u.exec(command)?.[1] - if (command.includes('__ORCAD_RECORD_PRESENT__')) { - const value = command.includes('transaction.json') ? this.journal : this.record - return value === null ? '__ORCAD_RECORD_ABSENT__\n' : `__ORCAD_RECORD_PRESENT__\n${value}` - } - if (command.includes('echo LOCKED || echo OPEN')) { - return this.fence ? 'LOCKED' : 'OPEN' - } - if (command.startsWith('rm -f') && command.includes('transaction.json')) { - return this.mutate(() => { - this.journal = null - this.fence = false - return '' - }) - } - if (command.includes('__ORCAD_BUILD_HASH__')) { - return `__ORCAD_BUILD_HASH__ ${BUILD_HASH}\n` - } - if (command.includes('orca-runtime.json')) { - return this.alive.size > 0 ? 'LIVE orcad.lock 1' : 'CLEAR' - } - if (command.includes('echo LIVE;') && version) { - if (this.alive.has(version)) { - return 'LIVE' - } - return this.pidFiles.has(version) ? 'DEAD' : 'UNKNOWN' - } - if (command.includes('kill -TERM') && version) { - if (!this.pidFiles.has(version)) { - return 'NO_PID' - } - return this.mutate(() => (this.alive.delete(version) ? 'STOPPED' : 'ALREADY_EXITED')) - } - if (command.includes('nohup') && version) { - return this.mutate(() => { - this.pidFiles.add(version) - // The instance lock and port admit one owner; a second launch never becomes ready. - if (this.alive.size === 0) { - this.alive.add(version) - if (version === NEW && !this.data.includes(MIGRATION)) { - this.data += MIGRATION - } - } - return '9999' - }) - } - if (command.startsWith('head -c ') && version) { - return this.alive.has(version) ? readyLine(version) : '' - } - if (command.includes('echo PRESENT') && snapshot) { - return this.snapshots.has(snapshot) ? 'PRESENT' : 'ABSENT' - } - if (command.includes('verdict=UNCHANGED') && snapshot) { - return this.snapshots.get(snapshot) === this.data ? 'UNCHANGED' : 'CHANGED' - } - if (command.includes('-cf') && snapshot) { - return this.mutate(() => { - if (this.data === '') { - return 'EMPTY' - } - this.snapshots.set(snapshot, this.data) - return 'CAPTURED' - }) - } - if (command.includes('.orcad-state-restore-stage') && snapshot) { - return this.mutate(() => { - const restored = this.snapshots.get(snapshot) - if (restored === undefined) { - return 'MISSING' - } - this.data = restored - return 'RESTORED' - }) - } - if (command.includes('then echo RESTORED')) { - return this.mutate(() => { - this.data = '' - return 'RESTORED' - }) - } - if (command.includes('stat -c %Y')) { - return 'UNKNOWN' - } - return '' - } -} - -export function readyLine(version: string): string { - return JSON.stringify({ - type: 'orca_server_ready', - schemaVersion: 1, - runtimeId: 'r1', - boundEndpoint: 'ws://127.0.0.1:7777', - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, - health: { - buildHash: BUILD_HASH, - buildVersion: version, - nodeVersion: '24.21.0', - nodeAbi: '137', - platform: 'linux', - arch: 'x64', - pid: 1, - terminalDaemon: { - state: 'live', - ownsFreshSessions: true, - pid: 2, - buildVersion: version, - entryPath: '/x/daemon-entry.js', - protocolVersion: 3, - selfTest: { ok: true, coverage: 'pty-spawn', verdict: 'healthy', durationMs: 5 } - } - } - }) -} diff --git a/src/main/ssh/orcad-activation-lock.ts b/src/main/ssh/orcad-activation-lock.ts deleted file mode 100644 index 7a0a5e9a6e0a..000000000000 --- a/src/main/ssh/orcad-activation-lock.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * One activation or rollback per host, and the fence an interrupted one leaves behind. - * - * The lock lives in the transaction root, so releasing it also removes the journal. A run - * that cannot prove the host is back to one serving slot retains both; only recovery, after - * the install lock's stale window, may take a retained fence over. - */ -import { - execOrcadRemote, - withoutAbortSignal, - type OrcadRemoteExecTarget -} from './orcad-remote-runtime-control' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { acquireInstallLock, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' -import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { removeRemoteFileCommand, removeRemoteTreeCommand } from './ssh-remote-commands' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { - ORCAD_ACTIVATION_TRANSACTION_DIRNAME, - ORCAD_ACTIVATION_TRANSACTION_FILENAME -} from './orcad-activation-transaction' - -const ORCAD_ACTIVATION_MAX_READINESS_TIMEOUT_MS = 5 * 60_000 - -type OrcadActivationLockOptions = OrcadRemoteExecTarget & { remoteHome: string } - -export type OrcadActivationLockControl = { - /** Keep the fence if the run throws: a journal now describes host state. */ - retainOnError(): void - /** Keep the fence even on return: the host is not proven back to one serving slot. */ - retain(): void - /** The host is proven back on its recorded slot: release even if the run then throws. */ - recovered(): void -} - -export function orcadActivationTransactionRoot( - host: RemoteHostPlatform, - remoteHome: string -): string { - return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, ORCAD_ACTIVATION_TRANSACTION_DIRNAME) -} - -/** Bounded so a crashed holder's lock goes stale long before a live holder could still be waiting. */ -export function resolveOrcadActivationReadinessTimeout( - configured: number | undefined, - fallback: number -): number { - const timeout = configured ?? fallback - if ( - !Number.isSafeInteger(timeout) || - timeout <= 0 || - timeout > ORCAD_ACTIVATION_MAX_READINESS_TIMEOUT_MS - ) { - throw new Error( - `orcad readiness timeout must be an integer from 1 to ${ORCAD_ACTIVATION_MAX_READINESS_TIMEOUT_MS}ms` - ) - } - return timeout -} - -export async function withOrcadActivationLock( - options: OrcadActivationLockOptions, - run: (control: OrcadActivationLockControl) => Promise -): Promise { - const lockRoot = orcadActivationTransactionRoot(options.host, options.remoteHome) - await acquireInstallLock(options.conn, lockRoot, options.host, { - signal: options.signal, - relayGcClaim: false, - // A retained fence means state ownership is unresolved. Age cannot make it safe. - allowStaleTakeover: false - }) - let retainOnError = false - let retain = false - try { - const result = await run({ - retainOnError: () => { - retainOnError = true - }, - retain: () => { - retain = true - }, - recovered: () => { - retainOnError = false - } - }) - if (!retain) { - await releaseActivationFence(options, lockRoot) - } - return result - } catch (error) { - // A remote mutation whose teardown is unconfirmed may still be running: keep its fence. - if (!retainOnError && !isUnconfirmedSshCommandTermination(error)) { - await releaseActivationFence(options, lockRoot).catch((releaseError: unknown) => { - console.warn( - `[orcad] Failed to release activation lock after an error: ${releaseError instanceof Error ? releaseError.message : String(releaseError)}` - ) - }) - } - throw error - } -} - -/** Takes over only a stale or previous-boot fence; a fresh one throws `RemoteInstallLockBusyError`. */ -export async function withStaleOrcadActivationRecoveryLock( - options: OrcadActivationLockOptions, - run: (control: Pick) => Promise -): Promise { - const lockRoot = orcadActivationTransactionRoot(options.host, options.remoteHome) - await acquireInstallLock(options.conn, lockRoot, options.host, { - signal: options.signal, - relayGcClaim: false, - allowStaleTakeover: true, - waitTimeoutMs: 0 - }) - let retain = false - // Any throw keeps the fence: recovery failed to prove one serving slot. - const result = await run({ retain: () => (retain = true) }) - if (!retain) { - await releaseActivationFence(options, lockRoot) - } - return result -} - -/** Whether any lock is held; a lost probe throws rather than reading as open. */ -export async function orcadActivationFenceExists( - options: OrcadActivationLockOptions -): Promise { - const lockDir = joinRemotePath( - options.host, - orcadActivationTransactionRoot(options.host, options.remoteHome), - RELAY_INSTALL_LOCK_NAME - ) - const answer = ( - await execOrcadRemote(options, probeInstallLockExistsCommand(options.host, lockDir)) - ).trim() - if (answer !== 'LOCKED' && answer !== 'OPEN') { - throw new Error('The activation fence probe returned no verifiable answer.') - } - return answer === 'LOCKED' -} - -function releaseActivationFence( - options: OrcadActivationLockOptions, - lockRoot: string -): Promise { - // Journal first: a release cut short must leave a lock without a journal, never the reverse. - const journal = joinRemotePath(options.host, lockRoot, ORCAD_ACTIVATION_TRANSACTION_FILENAME) - // Why no signal: a cancelled run must still be able to drop a fence it proved unnecessary. - return execOrcadRemote( - withoutAbortSignal(options), - `${removeRemoteFileCommand(options.host, journal)} && ${removeRemoteTreeCommand(options.host, lockRoot)}` - ).then(() => undefined) -} diff --git a/src/main/ssh/orcad-activation-recovery.ts b/src/main/ssh/orcad-activation-recovery.ts deleted file mode 100644 index 655da3b9fae0..000000000000 --- a/src/main/ssh/orcad-activation-recovery.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Finishing or undoing an activation or rollback that a crash, a lost connection or an - * unverifiable failure left fenced. Either way the host ends serving exactly the slot its - * activation record names, or the fence stays for an operator. - */ -import type { ServeReadiness } from '../server/serve-readiness' -import { - planOrcadTransactionRecovery, - type OrcadActivationTransaction -} from './orcad-activation-transaction' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { - readOrcadActivationRecord, - writeOrcadActivationRecord -} from './orcad-activation-record-store' -import { - orcadActivationFenceExists, - withStaleOrcadActivationRecoveryLock -} from './orcad-activation-lock' -import { RemoteInstallLockBusyError } from './ssh-relay-install-lock' -import { ensureOrcadSlotServing, resolveOrcadSlotIdentity } from './orcad-recovery-slot' -import { - recoverOrcadIncumbent, - type OrcadIncumbentRecoveryOptions -} from './orcad-incumbent-recovery' - -export type OrcadActivationRecoveryResult = - | { outcome: 'none' } - | { outcome: 'pending'; code: string; reason: string } - | { - outcome: 'recovered' - resolution: 'committed' | 'restored-incumbent' - activeVersion: string | null - readiness: ServeReadiness | null - } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } - -export type OrcadActivationRecoveryOptions = OrcadIncumbentRecoveryOptions - -export async function recoverInterruptedOrcadActivation( - options: OrcadActivationRecoveryOptions -): Promise { - try { - if ( - !(await readOrcadActivationTransaction(options)) && - !(await orcadActivationFenceExists(options)) - ) { - return { outcome: 'none' } - } - return await withStaleOrcadActivationRecoveryLock(options, async (lock) => { - const transaction = await readOrcadActivationTransaction(options) - if (!transaction) { - // A release cut short after removing the journal; dropping the lock finishes it. - return { outcome: 'none' } - } - const result = await reconcileOrcadTransaction(options, transaction) - if (result.outcome !== 'recovered') { - lock.retain() - } - return result - }) - } catch (error) { - if (error instanceof RemoteInstallLockBusyError) { - return { - outcome: 'pending', - code: 'orcad_recovery_transaction_still_fresh', - reason: - 'The activation fence is held by a run that may still be working. Retry after the ' + - 'install lock recovery window.' - } - } - return { - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_recovery_unverifiable', - reason: - `The interrupted activation could not be reconciled safely: ${errorMessage(error)} ` + - 'The host remains fenced.' - } - } -} - -/** Runs under a held fence. Throws when a step is unverifiable. */ -export async function reconcileOrcadTransaction( - options: OrcadActivationRecoveryOptions, - transaction: OrcadActivationTransaction -): Promise { - const plan = planOrcadTransactionRecovery(transaction, await readOrcadActivationRecord(options)) - if (plan.action === 'refuse') { - return { outcome: 'refused', verdict: 'unverifiable', code: plan.code, reason: plan.reason } - } - if (plan.action === 'finish-commit') { - await writeOrcadActivationRecord(options, plan.record) - } - if (plan.action === 'stabilize-committed' || plan.action === 'finish-commit') { - const activeVersion = plan.action === 'finish-commit' ? plan.record.active : plan.activeVersion - if (!activeVersion) { - throw new Error('The committed activation record has no active version.') - } - const identity = await resolveOrcadSlotIdentity(options, activeVersion) - return { - outcome: 'recovered', - resolution: 'committed', - activeVersion, - readiness: await ensureOrcadSlotServing(options, identity) - } - } - const recovery = await recoverOrcadIncumbent(options, { - transactionStartedAt: transaction.startedAt, - launchedVersion: plan.launchedVersion, - incumbent: plan.activeVersion - ? await resolveOrcadSlotIdentity(options, plan.activeVersion) - : null, - restoreState: plan.restoreState - }) - return recovery.outcome === 'refused' - ? recovery - : { - outcome: 'recovered', - resolution: 'restored-incumbent', - activeVersion: plan.activeVersion, - readiness: recovery.readiness - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-activation-transaction-schema.ts b/src/main/ssh/orcad-activation-transaction-schema.ts deleted file mode 100644 index 461bcd9a8f0d..000000000000 --- a/src/main/ssh/orcad-activation-transaction-schema.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { z } from 'zod' -import { - ORCAD_INSTALL_MODEL, - remoteInstallDirName, - remoteInstallVersionDirRegex -} from './remote-install-model' - -export const ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION = 1 - -const RemoteVersionSchema = z - .string() - .refine( - (version) => - remoteInstallVersionDirRegex(ORCAD_INSTALL_MODEL).test( - remoteInstallDirName(ORCAD_INSTALL_MODEL, version) - ), - 'Expected a safe remote install version' - ) -const SafeSnapshotNameSchema = z - .string() - .min(1) - .max(255) - .regex(/^[A-Za-z0-9][A-Za-z0-9.+-]*$/u) -const SnapshotVerdictSchema = z.object({ - dirName: SafeSnapshotNameSchema, - state: z.enum(['pending', 'captured', 'empty']) -}) -const OrcadActivationTransactionCommonFields = { - schemaVersion: z.literal(ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION), - transactionId: z.uuid(), - startedAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), - recordBefore: z.unknown() -} - -// Why strict operations: an older client reading a newer operation must keep the fence. -export const OrcadActivationTransactionSchema = z - .discriminatedUnion('operation', [ - z.object({ - ...OrcadActivationTransactionCommonFields, - operation: z.literal('activate'), - phase: z.enum(['prepared', 'incumbent-stopped', 'snapshot-captured', 'candidate-ready']), - candidateVersion: RemoteVersionSchema, - recordAfter: z.unknown().nullable(), - snapshot: SnapshotVerdictSchema - }), - z.object({ - ...OrcadActivationTransactionCommonFields, - operation: z.literal('rollback'), - phase: z.enum([ - 'prepared', - 'incumbent-stopped', - 'rescue-captured', - 'rollback-state-restored', - 'target-ready' - ]), - incumbentVersion: RemoteVersionSchema, - targetVersion: RemoteVersionSchema, - recordAfter: z.unknown(), - rescue: SnapshotVerdictSchema - }) - ]) - .superRefine((transaction, context) => { - const beforeVerdict = - transaction.phase === 'prepared' || transaction.phase === 'incumbent-stopped' - const verdict = transaction.operation === 'activate' ? transaction.snapshot : transaction.rescue - if (beforeVerdict && verdict.state !== 'pending') { - context.addIssue({ code: 'custom', message: 'Snapshot state advanced before its phase' }) - } - if (!beforeVerdict && verdict.state === 'pending') { - context.addIssue({ code: 'custom', message: 'Snapshot phase has no durable verdict' }) - } - if ( - transaction.operation === 'activate' && - (transaction.phase === 'candidate-ready') !== (transaction.recordAfter !== null) - ) { - context.addIssue({ code: 'custom', message: 'Committed record is inconsistent with phase' }) - } - }) diff --git a/src/main/ssh/orcad-activation-transaction-store.ts b/src/main/ssh/orcad-activation-transaction-store.ts deleted file mode 100644 index 1e1b92927e4b..000000000000 --- a/src/main/ssh/orcad-activation-transaction-store.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - ORCAD_ACTIVATION_TRANSACTION_FILENAME, - parseOrcadActivationTransaction, - serializeOrcadActivationTransaction, - type OrcadActivationTransaction -} from './orcad-activation-transaction' -import { orcadActivationTransactionRoot } from './orcad-activation-lock' -import { - readBoundedOrcadRemoteRecord, - writeAtomicOrcadRemoteRecord -} from './orcad-remote-record-file' -import type { OrcadRemoteExecTarget } from './orcad-remote-runtime-control' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -const ORCAD_ACTIVATION_TRANSACTION_MAX_BYTES = 64 * 1024 - -type OrcadActivationTransactionStoreOptions = OrcadRemoteExecTarget & { remoteHome: string } - -export function orcadActivationTransactionPath( - host: RemoteHostPlatform, - remoteHome: string -): string { - return joinRemotePath( - host, - orcadActivationTransactionRoot(host, remoteHome), - ORCAD_ACTIVATION_TRANSACTION_FILENAME - ) -} - -/** `null` only on a verified absence; an unreadable journal keeps the host fenced. */ -export async function readOrcadActivationTransaction( - options: OrcadActivationTransactionStoreOptions -): Promise { - const read = await readBoundedOrcadRemoteRecord( - options, - orcadActivationTransactionPath(options.host, options.remoteHome), - ORCAD_ACTIVATION_TRANSACTION_MAX_BYTES - ) - const parsed = parseOrcadActivationTransaction(read.state === 'present' ? read.raw : null) - if (parsed.state === 'unreadable') { - throw new Error(`Cannot read this host's orcad activation transaction: ${parsed.reason}`) - } - return parsed.state === 'ok' ? parsed.transaction : null -} - -export function writeOrcadActivationTransaction( - options: OrcadActivationTransactionStoreOptions, - transaction: OrcadActivationTransaction -): Promise { - return writeAtomicOrcadRemoteRecord( - options, - orcadActivationTransactionPath(options.host, options.remoteHome), - serializeOrcadActivationTransaction(transaction) - ) -} diff --git a/src/main/ssh/orcad-activation-transaction-transitions.ts b/src/main/ssh/orcad-activation-transaction-transitions.ts deleted file mode 100644 index 95facd02daad..000000000000 --- a/src/main/ssh/orcad-activation-transaction-transitions.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { OrcadActivationRecord } from './orcad-activation-record' -import { ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION } from './orcad-activation-transaction-schema' -import type { - OrcadActivateTransaction, - OrcadRollbackTransaction -} from './orcad-activation-transaction' - -export function createOrcadActivationTransaction(options: { - transactionId: string - candidateVersion: string - recordBefore: OrcadActivationRecord - snapshotDirName: string - now: Date -}): OrcadActivateTransaction { - const timestamp = options.now.toISOString() - return { - schemaVersion: ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION, - transactionId: options.transactionId, - operation: 'activate', - phase: 'prepared', - startedAt: timestamp, - updatedAt: timestamp, - candidateVersion: options.candidateVersion, - recordBefore: options.recordBefore, - recordAfter: null, - snapshot: { dirName: options.snapshotDirName, state: 'pending' } - } -} - -export function withOrcadActivationIncumbentStopped( - transaction: OrcadActivateTransaction, - now: Date -): OrcadActivateTransaction { - return { ...transaction, phase: 'incumbent-stopped', updatedAt: now.toISOString() } -} - -export function withOrcadActivationSnapshot( - transaction: OrcadActivateTransaction, - state: 'captured' | 'empty', - now: Date -): OrcadActivateTransaction { - return { - ...transaction, - phase: 'snapshot-captured', - updatedAt: now.toISOString(), - snapshot: { dirName: transaction.snapshot.dirName, state } - } -} - -export function withOrcadActivationCandidateReady( - transaction: OrcadActivateTransaction, - recordAfter: OrcadActivationRecord, - now: Date -): OrcadActivateTransaction { - return { ...transaction, phase: 'candidate-ready', updatedAt: now.toISOString(), recordAfter } -} - -export function createOrcadRollbackTransaction(options: { - transactionId: string - incumbentVersion: string - targetVersion: string - recordBefore: OrcadActivationRecord - recordAfter: OrcadActivationRecord - rescueDirName: string - now: Date -}): OrcadRollbackTransaction { - const timestamp = options.now.toISOString() - return { - schemaVersion: ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION, - transactionId: options.transactionId, - operation: 'rollback', - phase: 'prepared', - startedAt: timestamp, - updatedAt: timestamp, - incumbentVersion: options.incumbentVersion, - targetVersion: options.targetVersion, - recordBefore: options.recordBefore, - recordAfter: options.recordAfter, - rescue: { dirName: options.rescueDirName, state: 'pending' } - } -} - -export function withOrcadRollbackPhase( - transaction: OrcadRollbackTransaction, - phase: 'incumbent-stopped' | 'rollback-state-restored' | 'target-ready', - now: Date -): OrcadRollbackTransaction { - return { ...transaction, phase, updatedAt: now.toISOString() } -} - -export function withOrcadRollbackRescue( - transaction: OrcadRollbackTransaction, - state: 'captured' | 'empty', - now: Date -): OrcadRollbackTransaction { - return { - ...transaction, - phase: 'rescue-captured', - updatedAt: now.toISOString(), - rescue: { dirName: transaction.rescue.dirName, state } - } -} diff --git a/src/main/ssh/orcad-activation-transaction.test.ts b/src/main/ssh/orcad-activation-transaction.test.ts deleted file mode 100644 index 65fbb6227d08..000000000000 --- a/src/main/ssh/orcad-activation-transaction.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type * as DeployHelpers from './ssh-relay-deploy-helpers' -import type * as InstallLock from './ssh-relay-install-lock' - -vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ - ...(await importOriginal()), - execCommand: vi.fn().mockResolvedValue('') -})) -vi.mock('./ssh-relay-install-lock', async (importOriginal) => ({ - ...(await importOriginal()), - acquireInstallLock: vi.fn().mockResolvedValue(undefined) -})) - -import { execCommand } from './ssh-relay-deploy-helpers' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { - parseOrcadActivationTransaction, - planOrcadTransactionRecovery, - serializeOrcadActivationTransaction, - type OrcadActivationTransaction -} from './orcad-activation-transaction' -import { - createOrcadActivationTransaction, - createOrcadRollbackTransaction, - withOrcadActivationCandidateReady, - withOrcadActivationIncumbentStopped, - withOrcadActivationSnapshot, - withOrcadRollbackPhase, - withOrcadRollbackRescue -} from './orcad-activation-transaction-transitions' -import { - resolveOrcadActivationReadinessTimeout, - withOrcadActivationLock, - withStaleOrcadActivationRecoveryLock -} from './orcad-activation-lock' -import { FakeOrcadHost, NEW, OLD } from './orcad-activation-host-test-harness' -import { withRolledBackVersion } from './orcad-activation-record' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' - -const T = new Date('2026-02-02T00:00:00.000Z') -const ID = '7f1c2a7e-6c1b-4a8e-9f0e-0a1b2c3d4e5f' -const before = FakeOrcadHost.newRecord() - -function activation(): ReturnType { - return createOrcadActivationTransaction({ - transactionId: ID, - candidateVersion: '0.3.0+cc01', - recordBefore: before, - snapshotDirName: 'pre-0.3.0+cc01-1', - now: T - }) -} - -function rollback(): ReturnType { - return createOrcadRollbackTransaction({ - transactionId: ID, - incumbentVersion: NEW, - targetVersion: OLD, - recordBefore: before, - recordAfter: withRolledBackVersion(before, T), - rescueDirName: 'rollback-rescue-0.2.0+bb01-1', - now: T - }) -} - -const roundTrip = (transaction: OrcadActivationTransaction): unknown => - parseOrcadActivationTransaction(serializeOrcadActivationTransaction(transaction)) - -describe('parseOrcadActivationTransaction', () => { - it('round-trips every phase of both operations', () => { - const stopped = withOrcadActivationIncumbentStopped(activation(), T) - const captured = withOrcadActivationSnapshot(stopped, 'captured', T) - const ready = withOrcadActivationCandidateReady( - captured, - { ...before, active: '0.3.0+cc01' }, - T - ) - const rescued = withOrcadRollbackRescue( - withOrcadRollbackPhase(rollback(), 'incumbent-stopped', T), - 'empty', - T - ) - for (const transaction of [activation(), stopped, captured, ready, rollback(), rescued]) { - expect(roundTrip(transaction)).toEqual({ state: 'ok', transaction }) - } - }) - - it.each([ - ['an unknown operation, so an older client keeps a newer fence', { operation: 'decommission' }], - ['a snapshot verdict before its phase', { snapshot: { dirName: 'pre-x', state: 'captured' } }], - ['a committed record before candidate-ready', { recordAfter: before }], - ['an unsafe version', { candidateVersion: '../../etc' }], - ['an unsafe snapshot name', { snapshot: { dirName: '../x', state: 'pending' } }] - ])('reads %s as unreadable', (_name, patch) => { - const raw = JSON.stringify({ ...activation(), ...patch }) - expect(parseOrcadActivationTransaction(raw)).toMatchObject({ state: 'unreadable' }) - }) - - it('rejects a rollback whose versions disagree with its records', () => { - const raw = JSON.stringify({ ...rollback(), targetVersion: '0.0.9+dd01' }) - expect(parseOrcadActivationTransaction(raw)).toMatchObject({ state: 'unreadable' }) - }) -}) - -describe('planOrcadTransactionRecovery', () => { - const after = { ...before, active: '0.3.0+cc01' } - const captured = withOrcadActivationSnapshot( - withOrcadActivationIncumbentStopped(activation(), T), - 'captured', - T - ) - - it.each([ - ['prepared', activation(), null, null], - ['incumbent-stopped', withOrcadActivationIncumbentStopped(activation(), T), null, null], - ['snapshot-captured', captured, '0.3.0+cc01', 'captured'] - ] as const)('undoes an activation interrupted at %s', (_phase, transaction, launched, state) => { - expect(planOrcadTransactionRecovery(transaction, before)).toMatchObject({ - action: 'undo', - launchedVersion: launched, - activeVersion: NEW, - restoreState: state === null ? null : { state } - }) - }) - - it('finishes a commit whose record write was lost, and stabilizes one that landed', () => { - const ready = withOrcadActivationCandidateReady(captured, after, T) - expect(planOrcadTransactionRecovery(ready, before)).toEqual({ - action: 'finish-commit', - record: after - }) - expect(planOrcadTransactionRecovery(ready, after)).toEqual({ - action: 'stabilize-committed', - activeVersion: '0.3.0+cc01' - }) - }) - - it('restores the rescue once a rollback may have replaced the state', () => { - const rescued = withOrcadRollbackRescue(rollback(), 'captured', T) - expect(planOrcadTransactionRecovery(rescued, before)).toMatchObject({ - action: 'undo', - launchedVersion: null, - activeVersion: NEW, - restoreState: { state: 'captured' } - }) - expect( - planOrcadTransactionRecovery( - withOrcadRollbackPhase(rescued, 'rollback-state-restored', T), - before - ) - ).toMatchObject({ launchedVersion: OLD }) - }) - - it('refuses when the record matches neither side', () => { - expect(planOrcadTransactionRecovery(activation(), { ...before, previous: null })).toMatchObject( - { - action: 'refuse', - code: 'orcad_recovery_activation_record_changed' - } - ) - }) -}) - -describe('activation fence', () => { - const target = { - conn: {} as SshConnection, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/u' - } - const removals = (): string[] => - vi - .mocked(execCommand) - .mock.calls.map(([, command]) => command) - .filter((command) => command.includes('rm -')) - - it('never takes over a held fence by age, and removes the journal before the lock', async () => { - vi.clearAllMocks() - await withOrcadActivationLock(target, async () => undefined) - expect(vi.mocked(acquireInstallLock).mock.calls[0]?.[3]).toMatchObject({ - allowStaleTakeover: false, - relayGcClaim: false - }) - const [release] = removals() - expect(release?.indexOf('transaction.json')).toBeLessThan( - release?.indexOf("rm -rf '/home/u/.orca-remote/.orcad-activation-transaction'") ?? -1 - ) - }) - - it('keeps the fence after an unconfirmed termination, a retained error, or retain()', async () => { - vi.clearAllMocks() - const lost = Object.assign(new Error('lost'), { sshChannelCloseConfirmed: false }) - await expect(withOrcadActivationLock(target, () => Promise.reject(lost))).rejects.toBe(lost) - await expect( - withOrcadActivationLock(target, async (lock) => { - lock.retainOnError() - throw new Error('mid-transaction') - }) - ).rejects.toThrow('mid-transaction') - await withOrcadActivationLock(target, async (lock) => lock.retain()) - expect(removals()).toEqual([]) - }) - - it('releases after a recovered failure even though the run throws', async () => { - vi.clearAllMocks() - await expect( - withOrcadActivationLock(target, async (lock) => { - lock.retainOnError() - lock.recovered() - throw new Error('snapshot failed; incumbent restarted') - }) - ).rejects.toThrow('incumbent restarted') - expect(removals()).toHaveLength(1) - }) - - it('lets recovery take over only stale fences, without waiting', async () => { - vi.clearAllMocks() - await withStaleOrcadActivationRecoveryLock(target, async () => undefined) - expect(vi.mocked(acquireInstallLock).mock.calls[0]?.[3]).toMatchObject({ - allowStaleTakeover: true, - waitTimeoutMs: 0 - }) - }) - - it.each([0, -1, 5 * 60_000 + 1, 1.5])('rejects readiness timeout %s', (timeout) => { - expect(() => resolveOrcadActivationReadinessTimeout(timeout, 1)).toThrow() - }) -}) diff --git a/src/main/ssh/orcad-activation-transaction.ts b/src/main/ssh/orcad-activation-transaction.ts deleted file mode 100644 index a349a4fa8492..000000000000 --- a/src/main/ssh/orcad-activation-transaction.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * The host-side journal that makes an orcad activation or rollback crash-safe. - * - * Written before the first mutation and kept until the host is proven to serve exactly one - * slot again. The activation record stays the commit point: a journal whose `recordAfter` - * matches the record committed; one whose `recordBefore` matches did not, and recovery puts - * the pre-transaction slot and state back. Anything else keeps the fence for an operator. - */ -import { - parseOrcadActivationRecord, - serializeOrcadActivationRecord, - type OrcadActivationRecord -} from './orcad-activation-record' -import { - type ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION, - OrcadActivationTransactionSchema -} from './orcad-activation-transaction-schema' - -export const ORCAD_ACTIVATION_TRANSACTION_FILENAME = 'transaction.json' -export const ORCAD_ACTIVATION_TRANSACTION_DIRNAME = '.orcad-activation-transaction' - -export type OrcadSnapshotVerdict = { dirName: string; state: 'pending' | 'captured' | 'empty' } - -export type OrcadActivateTransaction = { - schemaVersion: typeof ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION - transactionId: string - operation: 'activate' - phase: 'prepared' | 'incumbent-stopped' | 'snapshot-captured' | 'candidate-ready' - startedAt: string - updatedAt: string - candidateVersion: string - recordBefore: OrcadActivationRecord - recordAfter: OrcadActivationRecord | null - snapshot: OrcadSnapshotVerdict -} - -export type OrcadRollbackTransaction = { - schemaVersion: typeof ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION - transactionId: string - operation: 'rollback' - phase: - | 'prepared' - | 'incumbent-stopped' - | 'rescue-captured' - | 'rollback-state-restored' - | 'target-ready' - startedAt: string - updatedAt: string - incumbentVersion: string - targetVersion: string - recordBefore: OrcadActivationRecord - recordAfter: OrcadActivationRecord - rescue: OrcadSnapshotVerdict -} - -export type OrcadActivationTransaction = OrcadActivateTransaction | OrcadRollbackTransaction - -export type OrcadActivationTransactionReadResult = - | { state: 'absent' } - | { state: 'ok'; transaction: OrcadActivationTransaction } - | { state: 'unreadable'; reason: string } - -/** What recovery must do; `undo` lists the state to put back once the new slot is quiescent. */ -export type OrcadTransactionRecoveryPlan = - | { action: 'stabilize-committed'; activeVersion: string | null } - /** The new slot passed its gate and the journal holds the record; only the write was lost. */ - | { action: 'finish-commit'; record: OrcadActivationRecord } - | { - action: 'undo' - /** The slot that may have run after the state changed hands. */ - launchedVersion: string | null - activeVersion: string | null - restoreState: OrcadSnapshotVerdict | null - } - | { action: 'refuse'; code: string; reason: string } - -export function parseOrcadActivationTransaction( - raw: string | null -): OrcadActivationTransactionReadResult { - if (raw === null || raw.trim() === '') { - return { state: 'absent' } - } - let json: unknown - try { - json = JSON.parse(raw) - } catch (error) { - return unreadable(`transaction is not JSON: ${errorMessage(error)}`) - } - const parsed = OrcadActivationTransactionSchema.safeParse(json) - if (!parsed.success) { - const issue = parsed.error.issues[0] - const path = issue?.path.length ? issue.path.join('.') : 'transaction' - return unreadable(`${path} is invalid: ${issue?.message ?? 'unknown shape'}`) - } - const recordBefore = parseNestedRecord(parsed.data.recordBefore, 'recordBefore') - if (recordBefore.state === 'unreadable') { - return recordBefore - } - if (parsed.data.operation === 'activate') { - const recordAfter = - parsed.data.recordAfter === null - ? null - : parseNestedRecord(parsed.data.recordAfter, 'recordAfter') - if (recordAfter?.state === 'unreadable') { - return recordAfter - } - if (recordAfter && recordAfter.record.active !== parsed.data.candidateVersion) { - return unreadable('recordAfter does not activate candidateVersion') - } - return { - state: 'ok', - transaction: { - ...parsed.data, - recordBefore: recordBefore.record, - recordAfter: recordAfter?.record ?? null - } - } - } - const recordAfter = parseNestedRecord(parsed.data.recordAfter, 'recordAfter') - if (recordAfter.state === 'unreadable') { - return recordAfter - } - if ( - recordBefore.record.active !== parsed.data.incumbentVersion || - recordBefore.record.previous !== parsed.data.targetVersion - ) { - return unreadable('rollback versions do not match recordBefore') - } - if ( - recordAfter.record.active !== parsed.data.targetVersion || - recordAfter.record.previous !== null || - recordAfter.record.snapshot !== null - ) { - return unreadable('recordAfter is not a completed rollback record') - } - return { - state: 'ok', - transaction: { - ...parsed.data, - recordBefore: recordBefore.record, - recordAfter: recordAfter.record - } - } -} - -export function serializeOrcadActivationTransaction( - transaction: OrcadActivationTransaction -): string { - return `${JSON.stringify(transaction, null, 2)}\n` -} - -export function planOrcadTransactionRecovery( - transaction: OrcadActivationTransaction, - currentRecord: OrcadActivationRecord -): OrcadTransactionRecoveryPlan { - if ( - transaction.recordAfter && - sameOrcadActivationRecord(currentRecord, transaction.recordAfter) - ) { - return { action: 'stabilize-committed', activeVersion: transaction.recordAfter.active } - } - if (!sameOrcadActivationRecord(currentRecord, transaction.recordBefore)) { - return { - action: 'refuse', - code: 'orcad_recovery_activation_record_changed', - reason: - `The activation record matches neither side of the interrupted ${transaction.operation}. ` + - 'Preserving the activation fence for operator inspection.' - } - } - if (transaction.phase === 'candidate-ready' || transaction.phase === 'target-ready') { - return { action: 'finish-commit', record: transaction.recordAfter ?? neverRecord() } - } - if (transaction.operation === 'activate') { - // The candidate launches only after the snapshot verdict is durable. - const launched = transaction.phase === 'snapshot-captured' - return { - action: 'undo', - launchedVersion: launched ? transaction.candidateVersion : null, - activeVersion: transaction.recordBefore.active, - restoreState: launched ? transaction.snapshot : null - } - } - // The rescue is the incumbent's state; it only needs restoring once the target's replaced it. - const replaced = transaction.phase === 'rollback-state-restored' - const rescued = replaced || transaction.phase === 'rescue-captured' - return { - action: 'undo', - launchedVersion: replaced ? transaction.targetVersion : null, - activeVersion: transaction.incumbentVersion, - // A crash mid-restore leaves the phase at rescue-captured with the root half replaced. - restoreState: rescued ? transaction.rescue : null - } -} - -export function sameOrcadActivationRecord( - left: OrcadActivationRecord, - right: OrcadActivationRecord -): boolean { - return serializeOrcadActivationRecord(left) === serializeOrcadActivationRecord(right) -} - -function parseNestedRecord( - value: unknown, - field: string -): { state: 'ok'; record: OrcadActivationRecord } | { state: 'unreadable'; reason: string } { - const parsed = parseOrcadActivationRecord(JSON.stringify(value)) - return parsed.state === 'ok' - ? { state: 'ok', record: parsed.record } - : unreadable( - `${field} is invalid: ${parsed.state === 'absent' ? 'record is absent' : parsed.reason}` - ) -} - -function neverRecord(): never { - throw new Error('A committed phase must carry its record; the schema enforces this.') -} - -function unreadable(reason: string): { state: 'unreadable'; reason: string } { - return { state: 'unreadable', reason } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-daemon-protocol-crossing.ts b/src/main/ssh/orcad-daemon-protocol-crossing.ts deleted file mode 100644 index 9a2506d0896d..000000000000 --- a/src/main/ssh/orcad-daemon-protocol-crossing.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * D7: whether live terminals survive an orcad restart onto another build. - * - * The daemon outlives every orcad restart, so after the swap the incoming build must route - * sessions owned by a daemon that may speak another protocol. It can only when it speaks that - * protocol or lists it as previous — the same rule `config/scripts/daemon-protocol-facts.mjs` - * (`canAttach`) applies to release pairs in CI. - */ -import { - PREVIOUS_DAEMON_PROTOCOL_VERSIONS, - PROTOCOL_VERSION -} from '../daemon/daemon-protocol-version' -import type { OrcadTerminalCensus } from './orcad-update-plan' - -export type OrcadDaemonProtocolFacts = { - protocolVersion: number - previousProtocolVersions: readonly number[] -} - -/** This client's build, which is also the orcad bundle it deploys. */ -export const CURRENT_ORCAD_DAEMON_PROTOCOL: OrcadDaemonProtocolFacts = { - protocolVersion: PROTOCOL_VERSION, - previousProtocolVersions: PREVIOUS_DAEMON_PROTOCOL_VERSIONS -} - -export function orcadBuildCanAttachDaemon( - reader: OrcadDaemonProtocolFacts, - ownerProtocolVersion: number -): boolean { - return ( - reader.protocolVersion === ownerProtocolVersion || - reader.previousProtocolVersions.includes(ownerProtocolVersion) - ) -} - -export type OrcadLiveDaemonCrossing = - | 'no-live-terminals' - | 'attachable' - | 'strands-live-terminals' - | 'unverifiable' - -/** An unknown session count or daemon protocol is planned for as live and unattachable. */ -export function assessOrcadLiveDaemonCrossing( - census: OrcadTerminalCensus, - incoming: OrcadDaemonProtocolFacts -): OrcadLiveDaemonCrossing { - if (census.liveSessions === 0) { - return 'no-live-terminals' - } - if (census.daemonProtocolVersion === null) { - return 'unverifiable' - } - return orcadBuildCanAttachDaemon(incoming, census.daemonProtocolVersion) - ? 'attachable' - : 'strands-live-terminals' -} diff --git a/src/main/ssh/orcad-incumbent-recovery.ts b/src/main/ssh/orcad-incumbent-recovery.ts deleted file mode 100644 index 5e68778c1a30..000000000000 --- a/src/main/ssh/orcad-incumbent-recovery.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Putting a host back to the slot and state it had before an activation or rollback. - * - * Shared by the in-flight failure paths and by crash recovery, so both apply one rule: state - * a launched slot may have changed is replaced only after that slot is proven exited, and only - * when the change provably carries no terminals — the slot exposed RPC, so a pre-launch census - * cannot vouch for it. - */ -import type { ServeReadiness } from '../server/serve-readiness' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { ORCAD_STATE_SNAPSHOT_DIR } from './orcad-activation-record' -import type { OrcadSnapshotVerdict } from './orcad-activation-transaction' -import { - clearOrcadStateSnapshotMembersCommand, - compareOrcadStateSnapshotCommand, - orcadSnapshotIsUnchanged, - parseOrcadSnapshotRestore, - restoreOrcadStateSnapshotCommand -} from './orcad-state-snapshot' -import { execOrcadRemote } from './orcad-remote-runtime-control' -import { - ensureOrcadSlotServing, - launchOrcadSlot, - quiesceInterruptedOrcadSlot, - type OrcadSlotIdentity, - type OrcadSlotOptions -} from './orcad-recovery-slot' -import { joinRemotePath } from './ssh-remote-platform' - -export type OrcadIncumbentRecoveryOptions = OrcadSlotOptions & { - /** - * Terminals started on the host since `since`, from a census taken now. Absent when no fresh - * census is available; `null` when the daemon did not answer. Both keep changed state. - */ - terminalsStartedSince?: (since: string) => Promise -} - -export type OrcadIncumbentRecovery = - | { outcome: 'restored'; readiness: ServeReadiness | null } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } - -export function orcadSnapshotPath(options: OrcadSlotOptions, dirName: string): string { - return joinRemotePath( - options.host, - options.remoteHome, - RELAY_REMOTE_DIR, - ORCAD_STATE_SNAPSHOT_DIR, - dirName - ) -} - -/** Throws when a step cannot be verified; the caller keeps the fence. */ -export async function recoverOrcadIncumbent( - options: OrcadIncumbentRecoveryOptions, - input: { - transactionStartedAt: string - launchedVersion: string | null - incumbent: OrcadSlotIdentity | null - restoreState: OrcadSnapshotVerdict | null - /** This run itself proved both slots exited, so no fresh liveness probe is needed. */ - slotsProvenExited?: boolean - } -): Promise { - const quiescence = - input.slotsProvenExited || !input.restoreState - ? 'exited' - : await quiesceInterruptedOrcadSlot(options, input.launchedVersion, input.incumbent) - // With no incumbent there is no older reader to protect; the record names nothing to serve. - if (quiescence === 'exited' && input.restoreState && input.incumbent) { - const decision = input.launchedVersion - ? await decideChangedStateRestore(options, input.transactionStartedAt, input.restoreState) - : 'restore' - if (decision !== 'restore' && decision !== 'unchanged') { - return decision - } - if (decision === 'restore') { - await restoreState(options, input.restoreState) - } - } - if (!input.incumbent) { - return { outcome: 'restored', readiness: null } - } - return { - outcome: 'restored', - readiness: input.slotsProvenExited - ? await launchOrcadSlot(options, input.incumbent) - : await ensureOrcadSlotServing(options, input.incumbent) - } -} - -async function decideChangedStateRestore( - options: OrcadIncumbentRecoveryOptions, - since: string, - state: OrcadSnapshotVerdict -): Promise<'unchanged' | 'restore' | Extract> { - if (state.state === 'captured') { - const snapshotDir = orcadSnapshotPath(options, state.dirName) - // Read-only, so a lost answer is just "changed". - const comparison = await execOrcadRemote( - options, - compareOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir) - ).catch(() => '') - if (orcadSnapshotIsUnchanged(comparison)) { - return 'unchanged' - } - } - const started = options.terminalsStartedSince - ? await options.terminalsStartedSince(since) - : undefined - if (started === 0) { - return 'restore' - } - const retained = - state.state === 'captured' ? ` at ${orcadSnapshotPath(options, state.dirName)}` : '' - return started === undefined || started === null - ? { - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_recovery_census_required', - reason: - 'The launched build is stopped, but profile state changed or could not be verified, ' + - 'so the previous build was not restarted against it. Current state and daemon ' + - 'terminals are preserved; recovery requires a fresh host terminal census before ' + - `restoring the prelaunch snapshot${retained}.` - } - : { - outcome: 'refused', - verdict: 'live', - code: 'orcad_recovery_orphans_live_terminals', - reason: - `${started} terminal${started === 1 ? '' : 's'} started after the interrupted ` + - 'change, and the prelaunch snapshot does not describe them. Restoring it would ' + - `orphan running work; state is preserved${retained}.` - } -} - -async function restoreState(options: OrcadSlotOptions, state: OrcadSnapshotVerdict): Promise { - if (state.state === 'pending') { - throw new Error('The interrupted transaction has no durable snapshot verdict.') - } - const command = - state.state === 'captured' - ? restoreOrcadStateSnapshotCommand( - options.host, - options.userDataDir, - orcadSnapshotPath(options, state.dirName) - ) - : clearOrcadStateSnapshotMembersCommand(options.host, options.userDataDir) - const restored = parseOrcadSnapshotRestore(await execOrcadRemote(options, command)) - if (restored !== 'restored') { - throw new Error(`The prelaunch state could not be restored (${restored}).`) - } -} diff --git a/src/main/ssh/orcad-initial-activation-admission.ts b/src/main/ssh/orcad-initial-activation-admission.ts deleted file mode 100644 index cfa85da4dabf..000000000000 --- a/src/main/ssh/orcad-initial-activation-admission.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Before the first managed activation, prove no unmanaged Orca runtime owns the data root. - * - * With no active record there is no incumbent to stop, but a hand-started orcad or headless - * Orca may still hold the shared root. Its owner records name a PID; a live, unreadable or - * unexpected record defers rather than starting a second owner beside it. - */ -import { ORCAD_LOCK_FILE_NAME } from '../orcad/orcad-instance-lock' -import { PRIMARY_RUNTIME_METADATA_FILE } from '../../shared/runtime-bootstrap' -import { shellEscape } from './ssh-connection-utils' -import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -const OWNER_RECORD_MAX_BYTES = 64 * 1024 - -export type OrcadInitialActivationAdmission = - | { decision: 'proceed' } - | { decision: 'defer'; code: string; reason: string } - -/** Runs on the candidate slot's own runtime, so the probe needs no host Node. */ -export function initialOrcadActivationAdmissionCommand( - host: RemoteHostPlatform, - userDataDir: string, - remoteInstallDir: string, - legacyNodePath: string -): string { - const owners = [ORCAD_LOCK_FILE_NAME, PRIMARY_RUNTIME_METADATA_FILE].map((name) => ({ - name, - path: joinRemotePath(host, userDataDir, name) - })) - const script = [ - 'const fs=require("node:fs");', - `const limit=${OWNER_RECORD_MAX_BYTES};`, - 'const owners=JSON.parse(process.argv[1]??"[]");', - 'const invalid=(owner)=>`UNVERIFIABLE ${owner.name}`;', - 'function probe(owner){let fd;', - 'try{const noFollow=fs.constants.O_NOFOLLOW;', - 'if(typeof noFollow!=="number")return invalid(owner);', - 'fd=fs.openSync(owner.path,fs.constants.O_RDONLY|noFollow);', - 'const before=fs.fstatSync(fd);', - 'if(!before.isFile()||before.size>limit)return invalid(owner);', - 'const buffer=Buffer.alloc(limit+1);let bytes=0;', - 'while(byteslimit||bytes!==after.size||before.size!==after.size||before.mtimeMs!==after.mtimeMs)', - 'return invalid(owner);', - 'const record=JSON.parse(buffer.subarray(0,bytes).toString("utf8"));', - 'const pid=record?.pid;', - 'if(!Number.isSafeInteger(pid)||pid<=0)return invalid(owner);', - 'try{process.kill(pid,0);return `LIVE ${owner.name} ${pid}`;}', - 'catch(error){if(error?.code==="ESRCH")return null;', - 'if(error?.code==="EPERM")return `LIVE ${owner.name} ${pid}`;', - 'return invalid(owner);}}', - 'catch(error){return error?.code==="ENOENT"?null:invalid(owner);}', - 'finally{if(fd!==undefined){try{fs.closeSync(fd);}catch{}}}}', - 'for(const owner of owners){const result=probe(owner);', - 'if(result){console.log(result);process.exit(0);}}console.log("CLEAR");' - ].join('') - // The subshell turns the selector's `exit 78` into silence, which parses as unverifiable. - return ( - `(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, legacyNodePath)}; ` + - `"$orcad_runtime" -e ${shellEscape(script)} ${shellEscape(JSON.stringify(owners))})` - ) -} - -export function parseInitialOrcadActivationAdmission( - output: string -): OrcadInitialActivationAdmission { - const result = output.trim().split(/\r?\n/u).pop()?.trim() ?? '' - if (result === 'CLEAR') { - return { decision: 'proceed' } - } - const live = /^LIVE ([^ ]+) ([1-9][0-9]*)$/u.exec(result) - if (live) { - return { - decision: 'defer', - code: 'orcad_initial_runtime_live', - reason: - `An unmanaged runtime owner is still live according to ${live[1]} (pid ${live[2]}). ` + - 'Stop that Orca runtime before converting this data root to managed orcad.' - } - } - const record = /^UNVERIFIABLE ([^ ]+)$/u.exec(result)?.[1] ?? 'owner record' - return { - decision: 'defer', - code: 'orcad_initial_runtime_unverifiable', - reason: - `The host could not safely interpret ${record}, so it cannot prove the shared data root ` + - 'is quiescent. Preserve the file, verify its owner on the host, and retry after the owner exits.' - } -} diff --git a/src/main/ssh/orcad-installed-activation.ts b/src/main/ssh/orcad-installed-activation.ts deleted file mode 100644 index d39605080529..000000000000 --- a/src/main/ssh/orcad-installed-activation.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * The locked half of a deploy: stop, snapshot, start and commit, journaled at every step. - * - * The journal is durable before the first mutation, so a crash at any point leaves enough on - * the host for `recoverInterruptedOrcadActivation` to finish or undo it. A run that ends with - * the host provably back on one slot drops the fence; one that cannot prove it keeps it. - */ -import { randomUUID } from 'node:crypto' -import type { OrcadDeployOptions, OrcadDeployResult } from './orcad-remote-deploy' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { withActivatedVersion, type OrcadStateSnapshot } from './orcad-activation-record' -import { - readOrcadActivationRecord, - writeOrcadActivationRecord -} from './orcad-activation-record-store' -import { evaluateOrcadActivation } from './orcad-activation-gate' -import { planOrcadUpdate } from './orcad-update-plan' -import { CURRENT_ORCAD_DAEMON_PROTOCOL } from './orcad-daemon-protocol-crossing' -import { ORCAD_LOG_FILENAME, type OrcadReadinessParse } from './orcad-remote-launch' -import { - captureOrcadStateSnapshotCommand, - orcadSnapshotDirName, - parseOrcadSnapshotCapture -} from './orcad-state-snapshot' -import { orcadStopFreedTheHost } from './orcad-remote-process-control' -import { joinRemotePath } from './ssh-remote-platform' -import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' -import { preflightInstalledOrcad } from './orcad-remote-preflight' -import type { OrcadActivationLockControl } from './orcad-activation-lock' -import type { OrcadActivateTransaction } from './orcad-activation-transaction' -import { - createOrcadActivationTransaction, - withOrcadActivationCandidateReady, - withOrcadActivationIncumbentStopped, - withOrcadActivationSnapshot -} from './orcad-activation-transaction-transitions' -import { - readOrcadActivationTransaction, - writeOrcadActivationTransaction -} from './orcad-activation-transaction-store' -import { - execOrcadRemote, - launchOrcadAndAwaitReadiness, - withoutAbortSignal -} from './orcad-remote-runtime-control' -import { - initialOrcadActivationAdmissionCommand, - parseInitialOrcadActivationAdmission -} from './orcad-initial-activation-admission' -import { - launchOrcadSlot, - orcadSlotDir, - resolveOrcadSlotIdentity, - stopOrcadSlot, - ORCAD_SLOT_STOP_WAIT_SECONDS, - type OrcadSlotIdentity -} from './orcad-recovery-slot' -import { orcadSnapshotPath, recoverOrcadIncumbent } from './orcad-incumbent-recovery' - -type Outcome = Extract - -export async function activateInstalledOrcad( - options: OrcadDeployOptions & { localOrcadDir: string }, - fullVersion: string, - remoteDir: string, - lock: OrcadActivationLockControl -): Promise { - const now = options.now ?? ((): Date => new Date()) - const notActivated = (code: string, reason: string): Outcome => ({ - outcome: 'installed-not-activated', - fullVersion, - code, - reason - }) - if (await readOrcadActivationTransaction(options)) { - // Holding the lock proves its writer is gone, but undoing its work needs a fresh census. - lock.retain() - return notActivated( - 'orcad_activation_recovery_required', - 'An earlier activation on this host was interrupted. Recover it before deploying again.' - ) - } - const record = await readOrcadActivationRecord(options) - const plan = planOrcadUpdate({ - record, - candidateVersion: fullVersion, - census: options.census, - candidateDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL, - ...(options.force !== undefined ? { force: options.force } : {}) - }) - if (plan.action === 'noop') { - return { outcome: 'already-active', fullVersion } - } - if (plan.action === 'defer') { - return notActivated(plan.code, plan.reason) - } - - try { - await preflightInstalledOrcad({ ...options, remoteInstallDir: remoteDir, fullVersion }) - } catch (error) { - options.signal?.throwIfAborted() - return notActivated( - 'orcad_candidate_preflight_failed', - `Candidate profile preflight failed; the incumbent was not stopped: ${errorMessage(error)}` - ) - } - - let incumbent: OrcadSlotIdentity | null = null - if (record.active) { - try { - incumbent = await resolveOrcadSlotIdentity(options, record.active) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return notActivated( - 'orcad_incumbent_identity_unverifiable', - `The active orcad ${record.active} build identity could not be verified before ` + - `stopping it: ${errorMessage(error)} Nothing was stopped.` - ) - } - } else { - const admission = parseInitialOrcadActivationAdmission( - await execOrcadRemote( - options, - initialOrcadActivationAdmissionCommand( - options.host, - options.userDataDir, - remoteDir, - options.nodePath - ) - ).catch((error: unknown) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return '' - }) - ) - if (admission.decision === 'defer') { - return notActivated(admission.code, admission.reason) - } - } - - const startedAt = now() - let transaction: OrcadActivateTransaction = createOrcadActivationTransaction({ - transactionId: randomUUID(), - candidateVersion: fullVersion, - recordBefore: record, - snapshotDirName: orcadSnapshotDirName(fullVersion, startedAt.getTime()), - now: startedAt - }) - await writeOrcadActivationTransaction(options, transaction) - lock.retainOnError() - - if (incumbent) { - const stopped = await stopOrcadSlot(options, incumbent.remoteDir, false) - if (!orcadStopFreedTheHost(stopped)) { - // Only a delivered SIGTERM can still change the host; otherwise nothing happened. - if (stopped === 'still-running') { - lock.retain() - } - return notActivated( - 'orcad_outgoing_stop_incomplete', - `Could not verify that orcad ${incumbent.version} exited within ` + - `${ORCAD_SLOT_STOP_WAIT_SECONDS}s (${stopped}). No snapshot was taken and the ` + - 'candidate was not started. Orca requires matching runtime readiness before ' + - 'signaling an incumbent and confirmed exit before snapshotting.' - ) - } - } - transaction = withOrcadActivationIncumbentStopped(transaction, now()) - await writeOrcadActivationTransaction(options, transaction) - - // A live SQLite WAL is not a backup boundary, so the snapshot waits for confirmed exit. - const snapshotDir = orcadSnapshotPath(options, transaction.snapshot.dirName) - const capture = parseOrcadSnapshotCapture( - await execOrcadRemote( - options, - captureOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir) - ).catch((error: unknown) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return 'FAILED' - }) - ) - if (capture === 'failed') { - const restarted = incumbent - ? ` The incumbent was stopped before snapshotting; ${await restartAfterSnapshotFailure(options, incumbent, lock)}` - : '' - throw new Error( - `Could not snapshot ${options.userDataDir} before activating ${fullVersion}. Orca's ` + - 'persisted state carries no schema version, so without a snapshot a rollback has no ' + - `way back. Refusing to activate.${restarted}` - ) - } - const snapshot: OrcadStateSnapshot | null = - capture === 'captured' - ? { - dirName: transaction.snapshot.dirName, - takenBeforeVersion: fullVersion, - readableByVersion: record.active, - takenAt: startedAt.toISOString() - } - : null - transaction = withOrcadActivationSnapshot(transaction, capture, now()) - await writeOrcadActivationTransaction(options, transaction) - - let parsed: OrcadReadinessParse | null = null - let launchError: unknown - try { - parsed = await launchOrcadAndAwaitReadiness(options, { - remoteInstallDir: remoteDir, - nodePath: options.nodePath, - fullVersion, - userDataDir: options.userDataDir, - bindHost: options.bindHost, - port: options.port - }) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - launchError = error - } - const verdict = evaluateOrcadActivation(parsed?.state === 'ready' ? parsed.readiness : null, { - buildHash: computeLocalOrcadBuildHash(options.localOrcadDir), - fullVersion - }) - if (verdict.decision === 'reject') { - const [code, reason] = - launchError === undefined - ? [verdict.code, verdict.reason] - : [ - 'orcad_candidate_launch_failed', - `The candidate failed while starting: ${errorMessage(launchError)}` - ] - const restored = await restoreAfterRejectedCandidate(options, transaction, incumbent, lock) - return notActivated( - code, - `${reason} Candidate stderr is at ` + - `${joinRemotePath(options.host, remoteDir, ORCAD_LOG_FILENAME)}. ${restored}` - ) - } - - const recordAfter = withActivatedVersion(record, fullVersion, snapshot, now()) - transaction = withOrcadActivationCandidateReady(transaction, recordAfter, now()) - await writeOrcadActivationTransaction(options, transaction) - await writeOrcadActivationRecord(options, recordAfter) - return { outcome: 'installed-and-activated', fullVersion, verdict } -} - -async function restartAfterSnapshotFailure( - options: OrcadDeployOptions, - incumbent: OrcadSlotIdentity, - lock: OrcadActivationLockControl -): Promise { - try { - await launchOrcadSlot(withoutAbortSignal(options), incumbent) - lock.recovered() - return `orcad ${incumbent.version} was restarted and is serving again.` - } catch (error) { - lock.retain() - return `restarting orcad ${incumbent.version} failed: ${errorMessage(error)} This host requires recovery.` - } -} - -/** Stop the candidate this run launched, then put the incumbent back only on safe state. */ -async function restoreAfterRejectedCandidate( - options: OrcadDeployOptions, - transaction: OrcadActivateTransaction, - incumbent: OrcadSlotIdentity | null, - lock: OrcadActivationLockControl -): Promise { - const recoveryOptions = withoutAbortSignal(options) - try { - const candidateDir = orcadSlotDir(options, transaction.candidateVersion) - const stopped = await stopOrcadSlot(recoveryOptions, candidateDir, true) - if (!orcadStopFreedTheHost(stopped)) { - lock.retain() - return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.` - } - const recovery = await recoverOrcadIncumbent(recoveryOptions, { - transactionStartedAt: transaction.startedAt, - launchedVersion: transaction.candidateVersion, - incumbent, - restoreState: transaction.snapshot, - slotsProvenExited: true - }) - if (recovery.outcome === 'refused') { - lock.retain() - return recovery.reason - } - return incumbent - ? `orcad ${incumbent.version} was restarted and is serving again.` - : 'No previous version was active, so this host is now serving nothing.' - } catch (error) { - lock.retain() - return `Restoring the previous version failed: ${errorMessage(error)} This host requires recovery.` - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-recovery-slot.ts b/src/main/ssh/orcad-recovery-slot.ts deleted file mode 100644 index 8e4cec5a7680..000000000000 --- a/src/main/ssh/orcad-recovery-slot.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Slot-level verdicts shared by activation, rollback and their crash recovery. - * - * Every verdict here is the execution host's: `exited` only on positive proof, and a probe - * that could not answer is `unverifiable` — never a reason to start a second slot. - */ -import type { ServeReadiness } from '../server/serve-readiness' -import type { OrcadActivationExpectation } from './orcad-activation-gate' -import { ORCAD_INSTALL_MODEL } from './remote-install-model' -import { computeRemoteInstallDir } from './ssh-relay-versioned-install' -import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash' -import { - launchOrcadSlotAndAwaitReadiness, - OrcadActiveReadinessError, - probeActiveOrcadReadiness -} from './orcad-active-readiness' -import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch' -import { - parseOrcadStopOutcome, - stopOrcadCommand, - type OrcadStopOutcome -} from './orcad-remote-process-control' -import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' -import { - initialOrcadActivationAdmissionCommand, - parseInitialOrcadActivationAdmission -} from './orcad-initial-activation-admission' - -export const ORCAD_SLOT_STOP_WAIT_SECONDS = 20 - -export type OrcadSlotOptions = OrcadRemoteExecTarget & { - remoteHome: string - /** Host Node for legacy slots only; a slot's own runtime marker wins. */ - nodePath: string - userDataDir: string - bindHost: string - port: number - readinessTimeoutMs?: number - sleep?: (ms: number) => Promise -} - -export type OrcadSlotIdentity = { version: string; remoteDir: string; buildHash: string } - -function expectation(identity: OrcadSlotIdentity): OrcadActivationExpectation { - return { buildHash: identity.buildHash, fullVersion: identity.version } -} - -export function orcadSlotDir(options: OrcadSlotOptions, version: string): string { - return computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, version) -} - -/** Reads the installed bytes' hash, so a later readiness payload can be matched to them. */ -export async function resolveOrcadSlotIdentity( - options: OrcadSlotOptions, - version: string -): Promise { - const remoteDir = orcadSlotDir(options, version) - return { version, remoteDir, buildHash: await readRemoteOrcadBuildHash(options, remoteDir) } -} - -export function launchOrcadSlot( - options: OrcadSlotOptions, - identity: OrcadSlotIdentity -): Promise { - return launchOrcadSlotAndAwaitReadiness( - options, - { - remoteInstallDir: identity.remoteDir, - nodePath: options.nodePath, - fullVersion: identity.version, - userDataDir: options.userDataDir, - bindHost: options.bindHost, - port: options.port - }, - expectation(identity) - ) -} - -/** Proves the slot serves, starting it only on proven exit. */ -export async function ensureOrcadSlotServing( - options: OrcadSlotOptions, - identity: OrcadSlotIdentity -): Promise { - const liveness = parseOrcadLiveness( - await execOrcadRemote(options, orcadLivenessProbeCommand(options.host, identity.remoteDir)) - ) - if (liveness === 'LIVE') { - return probeActiveOrcadReadiness( - { ...options, remoteInstallDir: identity.remoteDir }, - expectation(identity) - ) - } - if (liveness === 'UNKNOWN') { - throw new OrcadActiveReadinessError( - 'unverifiable', - `orcad ${identity.version} process state is unverifiable.` - ) - } - return launchOrcadSlot(options, identity) -} - -/** `justLaunched` only for a slot this run started; otherwise the readiness PID must agree. */ -export async function stopOrcadSlot( - options: OrcadSlotOptions, - remoteDir: string, - justLaunched: boolean -): Promise { - return parseOrcadStopOutcome( - await execOrcadRemote( - options, - stopOrcadCommand( - options.host, - remoteDir, - justLaunched - ? { waitSeconds: ORCAD_SLOT_STOP_WAIT_SECONDS, justLaunched: true } - : { waitSeconds: ORCAD_SLOT_STOP_WAIT_SECONDS, nodePath: options.nodePath } - ) - ) - ) -} - -export type OrcadSlotQuiescence = 'exited' | 'other-slot-serving' - -/** - * Before an interrupted run's state is replaced: the slot it started must be proven exited and - * the slot being restored must not be running, or must already be serving (nothing to undo). - * - * A missing PID file is not proof of exit: an SSH drop can kill the launcher after `nohup` - * but before it records `$!`, so the data root's owner records decide. - */ -export async function quiesceInterruptedOrcadSlot( - options: OrcadSlotOptions, - /** `null` when nothing was launched, so only `otherSlot` needs ruling out. */ - version: string | null, - otherSlot: OrcadSlotIdentity | null -): Promise { - const remoteDir = version === null ? null : orcadSlotDir(options, version) - const stopped = - remoteDir === null ? 'already-exited' : await stopOrcadSlot(options, remoteDir, false) - let exited = stopped === 'stopped' || stopped === 'already-exited' - if (stopped === 'unknown' && remoteDir !== null) { - // The readiness PID never matched, e.g. the slot died before readiness; liveness decides. - exited = (await slotLiveness(options, remoteDir)) === 'DEAD' - } - if (otherSlot) { - const other = await slotLiveness(options, otherSlot.remoteDir) - if (other === 'LIVE' && (exited || stopped === 'no-pid')) { - // The serving slot holds the instance lock, so a PID-less launch cannot own the state. - await probeActiveOrcadReadiness( - { ...options, remoteInstallDir: otherSlot.remoteDir }, - expectation(otherSlot) - ) - return 'other-slot-serving' - } - if (other !== 'DEAD') { - throw new OrcadActiveReadinessError( - 'unverifiable', - `orcad ${version ?? 'candidate'} (${stopped}) and ${otherSlot.version} (${other}) cannot both be ` + - 'ruled out as owners of the shared state; it was not replaced.' - ) - } - } - if (!exited && stopped === 'no-pid' && remoteDir !== null) { - const admission = parseInitialOrcadActivationAdmission( - await execOrcadRemote( - options, - initialOrcadActivationAdmissionCommand( - options.host, - options.userDataDir, - remoteDir, - options.nodePath - ) - ) - ) - exited = admission.decision === 'proceed' - } - if (!exited) { - throw new OrcadActiveReadinessError( - 'unverifiable', - `orcad ${version ?? 'candidate'} could not be confirmed stopped (${stopped}); replacing its state would be unsafe.` - ) - } - return 'exited' -} - -async function slotLiveness( - options: OrcadSlotOptions, - remoteDir: string -): Promise<'LIVE' | 'DEAD' | 'UNKNOWN'> { - return parseOrcadLiveness( - await execOrcadRemote(options, orcadLivenessProbeCommand(options.host, remoteDir)) - ) -} diff --git a/src/main/ssh/orcad-remote-deploy-stop.ts b/src/main/ssh/orcad-remote-deploy-stop.ts new file mode 100644 index 000000000000..8a00a1dfa037 --- /dev/null +++ b/src/main/ssh/orcad-remote-deploy-stop.ts @@ -0,0 +1,68 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { computeRemoteInstallDir } from './ssh-relay-versioned-install' +import { + parseOrcadStopOutcome, + stopOrcadCommand, + type OrcadStopOutcome +} from './orcad-remote-process-control' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { compareOrcadStateSnapshotCommand, orcadSnapshotIsUnchanged } from './orcad-state-snapshot' + +export type OrcadOutgoingStopOptions = { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + nodePath: string + signal?: AbortSignal +} + +/** Stop the outgoing runtime and return its execution-host verdict. */ +export async function stopOutgoingOrcad( + options: OrcadOutgoingStopOptions, + outgoingVersion: string +): Promise { + const outgoingDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + outgoingVersion + ) + const output = await execCommand( + options.conn, + stopOrcadCommand(options.host, outgoingDir, { waitSeconds: 20, nodePath: options.nodePath }), + { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + } + ) + return parseOrcadStopOutcome(output) +} + +/** The caller must confirm candidate exit before inspecting its shared state. */ +export async function rejectedOrcadStateRecoveryRefusal( + options: OrcadOutgoingStopOptions & { userDataDir: string }, + incumbentVersion: string, + snapshotDir: string | undefined +): Promise { + const unchanged = snapshotDir + ? orcadSnapshotIsUnchanged( + await execCommand( + options.conn, + compareOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir), + { wrapCommand: options.host.commandDialect !== 'powershell', signal: options.signal } + ).catch(() => '') + ) + : false + if (unchanged) { + return undefined + } + // RPC was already exposed; a prelaunch census cannot authorize discarding candidate writes. + const retainedSnapshot = snapshotDir ? ` at ${snapshotDir}.` : ', which is unavailable.' + return ( + 'The candidate is stopped, but profile state changed or could not be verified. ' + + `orcad ${incumbentVersion} was not restarted against potentially incompatible state. ` + + 'Current state and daemon terminals are preserved; recovery requires a fresh host ' + + `terminal census before restoring the prelaunch snapshot${retainedSnapshot}` + ) +} diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index 29af8774db9c..be2dd5223d29 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -59,7 +59,6 @@ vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ })) function readyLine(overrides: { - version?: string buildHash?: string daemonState?: 'live' | 'degraded' | 'absent' selfTestOk?: boolean @@ -74,7 +73,7 @@ function readyLine(overrides: { pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, health: { buildHash: overrides.buildHash ?? 'abc123def4567890', - buildVersion: overrides.version ?? NEW_VERSION, + buildVersion: NEW_VERSION, nodeVersion: '20.11.0', nodeAbi: '115', platform: 'linux', @@ -118,15 +117,6 @@ function scriptHost(script: HostScript): void { ? `__ORCAD_RECORD_PRESENT__\n${script.activationRecord}` : '__ORCAD_RECORD_ABSENT__\n' } - if (text.includes('__ORCAD_RECORD_PRESENT__') && text.includes('transaction.json')) { - return '__ORCAD_RECORD_ABSENT__\n' - } - if (text.includes('__ORCAD_BUILD_HASH__')) { - return '__ORCAD_BUILD_HASH__ abc123def4567890\n' - } - if (text.includes('orcad.lock') && text.includes('orca-runtime.json')) { - return 'CLEAR' - } if (text.includes('.orcad-readiness') && text.startsWith('head -c ')) { if (script.readinessAtMs !== undefined && Date.now() < script.readinessAtMs) { return '' @@ -180,7 +170,7 @@ function options(overrides: Partial = {}): OrcadDeployOption userDataDir: '/home/u/.orca', bindHost: '127.0.0.1', port: 7777, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, readinessTimeoutMs: 50, sleep: async () => {}, now: () => new Date('2026-02-02T00:00:00.000Z'), @@ -325,7 +315,7 @@ describe('deployOrcad', () => { options({ host: getRemoteHostPlatform(platform), target, - census: { liveSessions: 1, startedSinceActivation: 0, daemonProtocolVersion: 3 } + census: { liveSessions: 1, startedSinceActivation: 0 } }) ) const chmod = mockExec.mock.calls.findIndex(([, command]) => @@ -488,7 +478,7 @@ describe('deployOrcad', () => { } scriptHost(script) const result = await deployOrcad( - options({ census: { liveSessions: 2, startedSinceActivation: 0, daemonProtocolVersion: 3 } }) + options({ census: { liveSessions: 2, startedSinceActivation: 0 } }) ) expect(result).toMatchObject({ outcome: 'installed-not-activated', @@ -520,7 +510,7 @@ describe('deployOrcad', () => { activationRecord: ACTIVE_OLD, readiness: { [NEW_VERSION]: readyLine({ selfTestOk: false }), - [OLD_VERSION]: readyLine({ version: OLD_VERSION }) + [OLD_VERSION]: readyLine({}) }, log: [] } @@ -578,7 +568,7 @@ describe('deployOrcad', () => { it('restarts the incumbent when a quiescent snapshot cannot be captured', async () => { const script: HostScript = { activationRecord: ACTIVE_OLD, - readiness: { [OLD_VERSION]: readyLine({ version: OLD_VERSION }) }, + readiness: { [OLD_VERSION]: readyLine({}) }, log: [], snapshotResult: 'tar: write failed' } @@ -621,7 +611,7 @@ describe('deployOrcad', () => { activationRecord: ACTIVE_OLD, readiness: { [NEW_VERSION]: readyLine({ buildHash: 'deadbeefdeadbeef' }), - [OLD_VERSION]: readyLine({ version: OLD_VERSION }) + [OLD_VERSION]: readyLine({}) }, log: [] } diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index 0e74a106a38d..95c184e47b43 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -2,31 +2,48 @@ * Activate installed bytes only after the candidate proves healthy. A rejected candidate * allows restarting the incumbent only when profile state is provably unchanged; otherwise * preserve current state and the prelaunch snapshot for explicit recovery. - * - * Every activation runs under the host's activation fence and journal - * (`orcad-activation-lock.ts`), so an interrupted one is recoverable to exactly one slot. */ -import { join } from 'node:path' import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' import { ORCAD_INSTALL_MODEL } from './remote-install-model' import { computeRemoteInstallDir, readLocalFullVersion } from './ssh-relay-versioned-install' -import { readOrcadActivationRecord } from './orcad-activation-record-store' -import type { OrcadActivationVerdict } from './orcad-activation-gate' -import type { OrcadTerminalCensus } from './orcad-update-plan' -import type { RemoteHostPlatform } from './ssh-remote-platform' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { + ORCAD_STATE_SNAPSHOT_DIR, + withActivatedVersion, + type OrcadActivationRecord, + type OrcadStateSnapshot +} from './orcad-activation-record' +import { + readOrcadActivationRecord, + writeOrcadActivationRecord +} from './orcad-activation-record-store' +import { evaluateOrcadActivation, type OrcadActivationVerdict } from './orcad-activation-gate' +import { planOrcadUpdate, type OrcadTerminalCensus } from './orcad-update-plan' +import { ORCAD_LOG_FILENAME } from './orcad-remote-launch' +import { launchOrcadAndAwaitReadiness } from './orcad-remote-runtime-control' +import { rejectedOrcadStateRecoveryRefusal, stopOutgoingOrcad } from './orcad-remote-deploy-stop' +import { + captureOrcadStateSnapshotCommand, + orcadSnapshotDirName, + parseOrcadSnapshotCapture +} from './orcad-state-snapshot' +import { + orcadStopFreedTheHost, + parseOrcadStopOutcome, + stopOrcadCommand +} from './orcad-remote-process-control' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' +import { preflightInstalledOrcad } from './orcad-remote-preflight' import { assertPosixOrcadHost } from './orcad-remote-host-support' import { installOrcadBundle } from './orcad-remote-install' +import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import type { ServerTarget } from '../../shared/node-runtime-pin' -import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' import { materializeOrcadArtifact } from './orcad-artifact-materializer' import { readOrcadBundleTarget, resolveOrcadDeploymentTarget } from './orcad-deployment-target' import { materializeNodeRuntimeArchive } from './pinned-runtime-materializer' -import { - resolveOrcadActivationReadinessTimeout, - withOrcadActivationLock -} from './orcad-activation-lock' -import { activateInstalledOrcad } from './orcad-installed-activation' export type OrcadDeployOptions = { conn: SshConnection @@ -60,6 +77,111 @@ export type OrcadDeployResult = | { outcome: 'already-active'; fullVersion: string } | { outcome: 'installed-not-activated'; fullVersion: string; code: string; reason: string } +const STOP_WAIT_SECONDS = 20 + +function exec(options: OrcadDeployOptions, command: string): Promise { + return execCommand(options.conn, command, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + }) +} + +function baseDir(options: OrcadDeployOptions): string { + return joinRemotePath(options.host, options.remoteHome, RELAY_REMOTE_DIR) +} + +async function captureSnapshot( + options: OrcadDeployOptions, + fullVersion: string, + outgoingVersion: string | null, + takenAt: Date +): Promise { + // The caller has already stopped the outgoing runtime. This is required once profile state + // includes SQLite: a tar of a live WAL, main database, and SHM file is not a SQLite backup. + const dirName = orcadSnapshotDirName(fullVersion, takenAt.getTime()) + const snapshotDir = joinRemotePath( + options.host, + baseDir(options), + ORCAD_STATE_SNAPSHOT_DIR, + dirName + ) + const capture = parseOrcadSnapshotCapture( + await exec( + options, + captureOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir) + ) + ) + if (capture === 'failed') { + throw new Error( + `Could not snapshot ${options.userDataDir} before activating ${fullVersion}. Orca's ` + + 'persisted state carries no schema version, so without a snapshot a rollback has no ' + + 'way back. Refusing to activate.' + ) + } + // Empty profiles need no rollback snapshot. + if (capture === 'empty') { + return null + } + return { + dirName, + takenBeforeVersion: fullVersion, + readableByVersion: outgoingVersion, + takenAt: takenAt.toISOString() + } +} + +function launchAndAwaitReadiness( + options: OrcadDeployOptions, + remoteInstallDir: string, + fullVersion: string +): ReturnType { + return launchOrcadAndAwaitReadiness(options, { ...options, remoteInstallDir, fullVersion }) +} + +/** Restart the incumbent only when the candidate left shared state unchanged. */ +async function restoreIncumbent( + options: OrcadDeployOptions, + record: OrcadActivationRecord, + candidateDir?: string, + snapshot?: OrcadStateSnapshot | null +): Promise { + if (candidateDir) { + const stopped = parseOrcadStopOutcome( + await exec( + options, + stopOrcadCommand(options.host, candidateDir, { + waitSeconds: STOP_WAIT_SECONDS, + justLaunched: true + }) + ) + ) + if (!orcadStopFreedTheHost(stopped)) { + return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.` + } + } + if (!record.active) { + return 'No previous version was active, so this host is now serving nothing.' + } + if (candidateDir) { + const snapshotDir = snapshot + ? joinRemotePath(options.host, baseDir(options), ORCAD_STATE_SNAPSHOT_DIR, snapshot.dirName) + : undefined + const refusal = await rejectedOrcadStateRecoveryRefusal(options, record.active, snapshotDir) + if (refusal) { + return refusal + } + } + const incumbentDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + record.active + ) + const parsed = await launchAndAwaitReadiness(options, incumbentDir, record.active) + return parsed.state === 'ready' + ? `orcad ${record.active} was restarted and is serving again.` + : `orcad ${record.active} was relaunched but has not published readiness; this host may be down.` +} + /** Activate on a healthy verdict; retain changed candidate state for explicit recovery. */ export async function deployOrcad(input: OrcadDeployOptions): Promise { assertPosixOrcadHost(input.host) @@ -71,10 +193,6 @@ export async function deployOrcad(input: OrcadDeployOptions): Promise materializeNodeRuntimeArchive( target, @@ -84,14 +202,105 @@ export async function deployOrcad(input: OrcadDeployOptions): Promise new Date()) const fullVersion = readLocalFullVersion(options.localOrcadDir) const remoteDir = computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, fullVersion) - // Fail fast before upload; the activation re-reads it under the fence. - await readOrcadActivationRecord(options) + const record = await readOrcadActivationRecord(options) await installOrcadBundle(options, fullVersion, remoteDir) - return withOrcadActivationLock(options, (lock) => - activateInstalledOrcad(options, fullVersion, remoteDir, lock) + const plan = planOrcadUpdate({ + record, + candidateVersion: fullVersion, + census: options.census, + ...(options.force !== undefined ? { force: options.force } : {}) + }) + if (plan.action === 'noop') { + return { outcome: 'already-active', fullVersion } + } + if (plan.action === 'defer') { + return { + outcome: 'installed-not-activated', + fullVersion, + code: plan.code, + reason: plan.reason + } + } + + try { + await preflightInstalledOrcad({ + ...options, + remoteInstallDir: remoteDir, + fullVersion + }) + } catch (error) { + options.signal?.throwIfAborted() + return { + outcome: 'installed-not-activated', + fullVersion, + code: 'orcad_candidate_preflight_failed', + reason: `Candidate profile preflight failed; the incumbent was not stopped: ${ + error instanceof Error ? error.message : String(error) + }` + } + } + + if (record.active) { + const stopped = await stopOutgoingOrcad(options, record.active) + if (!orcadStopFreedTheHost(stopped)) { + return { + outcome: 'installed-not-activated', + fullVersion, + code: 'orcad_outgoing_stop_incomplete', + reason: + `Could not verify that orcad ${record.active} exited (${stopped}). ` + + 'No snapshot was taken and the candidate was not started. Orca requires matching ' + + 'runtime readiness before signaling an incumbent and confirmed exit before snapshotting.' + } + } + } + + // A live SQLite WAL is not a backup boundary: tar can observe the main file, WAL and SHM + // at different points and restore a set SQLite cannot recover. Stop the incumbent first so + // its final durable flush has completed before capturing the pre-activation state. + let snapshot: OrcadStateSnapshot | null = null + if (record.active) { + try { + snapshot = await captureSnapshot(options, fullVersion, record.active, now()) + } catch (error) { + const restored = await restoreIncumbent(options, record).catch( + (restartError: unknown) => + `The incumbent could not be restarted: ${ + restartError instanceof Error ? restartError.message : String(restartError) + }` + ) + throw new Error( + `${error instanceof Error ? error.message : String(error)} The incumbent was stopped ` + + `before snapshotting; ${restored}` + ) + } + } + + const parsed = await launchAndAwaitReadiness(options, remoteDir, fullVersion) + const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, { + buildHash: computeLocalOrcadBuildHash(options.localOrcadDir), + fullVersion + }) + if (verdict.decision === 'reject') { + const restored = await restoreIncumbent(options, record, remoteDir, snapshot) + return { + outcome: 'installed-not-activated', + fullVersion, + code: verdict.code, + reason: + `${verdict.reason} Candidate stderr is at ` + + `${joinRemotePath(options.host, remoteDir, ORCAD_LOG_FILENAME)}. ${restored}` + } + } + + await writeOrcadActivationRecord( + options, + withActivatedVersion(record, fullVersion, snapshot, now()) ) + return { outcome: 'installed-and-activated', fullVersion, verdict } } diff --git a/src/main/ssh/orcad-remote-rollback.test.ts b/src/main/ssh/orcad-remote-rollback.test.ts index 02aada05b2b1..5d2dd2e558eb 100644 --- a/src/main/ssh/orcad-remote-rollback.test.ts +++ b/src/main/ssh/orcad-remote-rollback.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as RecordFile from './orcad-remote-record-file' -import type * as InstallLock from './ssh-relay-install-lock' vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn(), @@ -11,10 +10,6 @@ vi.mock('./ssh-relay-install-transfers', () => ({ writeRelayFile: vi.fn().mockResolvedValue(undefined), uploadRelayDirectory: vi.fn().mockResolvedValue(undefined) })) -vi.mock('./ssh-relay-install-lock', async (importOriginal) => ({ - ...(await importOriginal()), - acquireInstallLock: vi.fn().mockResolvedValue(undefined) -})) vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ ...(await importOriginal()), writeAtomicOrcadRemoteRecord: vi.fn().mockResolvedValue(undefined) @@ -78,47 +73,28 @@ function readyLine(version: string): string { }) } -type HostOverrides = { - restores?: string[] - readinessAtMs?: number - targetReady?: boolean - snapshot?: string - comparison?: string -} - -function scriptHost(log: string[], overrides: HostOverrides = {}): void { - const restores = [...(overrides.restores ?? [])] +function scriptHost( + log: string[], + overrides: { restore?: string; readinessAtMs?: number } = {} +): void { mockExec.mockImplementation(async (_conn, command: string) => { const text = String(command) if (text.includes('__ORCAD_RECORD_PRESENT__')) { - return text.includes('transaction.json') - ? '__ORCAD_RECORD_ABSENT__\n' - : `__ORCAD_RECORD_PRESENT__\n${JSON.stringify(record())}` - } - if (text.includes('__ORCAD_BUILD_HASH__')) { - return `__ORCAD_BUILD_HASH__ ${BUILD_HASH}\n` + return `__ORCAD_RECORD_PRESENT__\n${JSON.stringify(record())}` } - if (text.includes('echo PRESENT')) { - return overrides.snapshot ?? 'PRESENT' + if (text.includes('state.tar') && text.includes('test -f') && !text.includes('tar -C')) { + return 'PRESENT' } - if (text.includes('stat -c %Y')) { + if (text.includes('find ') && text.includes('stat')) { return 'UNKNOWN' } if (text.includes('kill -TERM')) { log.push(`stop:${text.includes(ACTIVE) ? ACTIVE : TARGET}`) return 'STOPPED' } - if (text.includes('verdict=UNCHANGED')) { - log.push('compare') - return overrides.comparison ?? 'CHANGED' - } - if (text.includes('tar -C') && text.includes('-cf')) { - log.push('rescue') - return 'CAPTURED' - } if (text.includes('tar -C') && text.includes('-xf')) { - log.push(text.includes('rollback-rescue-') ? 'restore-rescue' : 'restore') - return restores.shift() ?? 'RESTORED' + log.push('restore') + return overrides.restore ?? 'RESTORED' } if (text.includes('nohup')) { log.push(`launch:${text.includes(ACTIVE) ? ACTIVE : TARGET}`) @@ -128,10 +104,7 @@ function scriptHost(log: string[], overrides: HostOverrides = {}): void { if (overrides.readinessAtMs !== undefined && Date.now() < overrides.readinessAtMs) { return '' } - if (text.includes(ACTIVE)) { - return readyLine(ACTIVE) - } - return overrides.targetReady === false ? '' : readyLine(TARGET) + return readyLine(TARGET) } return '' }) @@ -147,9 +120,8 @@ function options(overrides: Partial = {}): OrcadRollbackOp userDataDir: '/home/u/.orca', bindHost: '127.0.0.1', port: 7777, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, targetBuildHash: BUILD_HASH, - targetDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] }, readinessTimeoutMs: 50, sleep: async () => {}, now: () => new Date('2026-02-02T00:00:00.000Z'), @@ -169,7 +141,7 @@ describe('rollbackOrcad', () => { expect(result).toMatchObject({ outcome: 'rolled-back', target: TARGET }) // Restoring under a running orcad would replace the store beneath a process holding it; // starting first would let the older build migrate the newer build's state. - expect(log).toEqual([`stop:${ACTIVE}`, 'rescue', 'restore', `launch:${TARGET}`]) + expect(log).toEqual([`stop:${ACTIVE}`, 'restore', `launch:${TARGET}`]) }) it('allows rollback startup time after a slow bundled preflight', async () => { @@ -188,7 +160,7 @@ describe('rollbackOrcad', () => { ) expect(result.outcome).toBe('rolled-back') expect(elapsedMs).toBe(100_000) - expect(log).toEqual([`stop:${ACTIVE}`, 'rescue', 'restore', `launch:${TARGET}`]) + expect(log).toEqual([`stop:${ACTIVE}`, 'restore', `launch:${TARGET}`]) } finally { clock.mockRestore() } @@ -198,7 +170,7 @@ describe('rollbackOrcad', () => { const log: string[] = [] scriptHost(log) const result = await rollbackOrcad( - options({ census: { liveSessions: 3, startedSinceActivation: 2, daemonProtocolVersion: 3 } }) + options({ census: { liveSessions: 3, startedSinceActivation: 2 } }) ) expect(result).toMatchObject({ outcome: 'refused', @@ -210,67 +182,45 @@ describe('rollbackOrcad', () => { it('refuses when the snapshot is gone from the host', async () => { const log: string[] = [] - scriptHost(log, { snapshot: 'ABSENT' }) + mockExec.mockImplementation(async (_conn, command: string) => + String(command).includes('state.tar') ? 'ABSENT' : '' + ) const result = await rollbackOrcad(options()) expect(result).toMatchObject({ outcome: 'refused', code: 'orcad_rollback_snapshot_missing' }) expect(log).toEqual([]) }) - it('does not read a lost snapshot probe as a missing snapshot', async () => { + it('does not start the old build when the restore failed', async () => { const log: string[] = [] - scriptHost(log, { snapshot: '' }) - const result = await rollbackOrcad(options()) - expect(result).toMatchObject({ - outcome: 'refused', - code: 'orcad_rollback_snapshot_unverifiable' - }) - expect(log).toEqual([]) - }) - - it('puts the rescued state and the newer build back when the restore failed', async () => { - const log: string[] = [] - scriptHost(log, { restores: ['FAILED'] }) + scriptHost(log, { restore: 'FAILED' }) const result = await rollbackOrcad(options()) expect(result).toMatchObject({ outcome: 'failed', code: 'orcad_rollback_restore_failed' }) - expect(log).toEqual([ - `stop:${ACTIVE}`, - 'rescue', - 'restore', - 'restore-rescue', - `launch:${ACTIVE}` - ]) - expect(result.outcome === 'failed' && result.reason).toContain('is serving again') + expect(log).toEqual([`stop:${ACTIVE}`, 'restore']) + expect(result.outcome === 'failed' && result.reason).toContain('Do NOT start the older build') }) - it('keeps the newer state when a failed target may have changed it and no census is fresh', async () => { + it('leaves the record naming the newer version when the target fails to come up', async () => { const log: string[] = [] - scriptHost(log, { targetReady: false }) + scriptHost(log) + mockExec.mockImplementation(async (_conn, command: string) => { + const text = String(command) + if (text.includes('state.tar') && text.includes('test -f') && !text.includes('tar -C')) { + return 'PRESENT' + } + if (text.includes('kill -TERM')) { + return 'STOPPED' + } + if (text.includes('tar -C') && text.includes('-xf')) { + return 'RESTORED' + } + // The target never publishes readiness. + return '' + }) const result = await rollbackOrcad(options()) expect(result).toMatchObject({ outcome: 'failed', code: 'orcad_activation_no_readiness' }) - expect(result.outcome === 'failed' && result.reason).toContain('fresh host terminal census') - expect(log).toEqual([ - `stop:${ACTIVE}`, - 'rescue', - 'restore', - `launch:${TARGET}`, - `stop:${TARGET}`, - 'compare' - ]) // Until the target is proven serving, `active` must still name the version an operator // would have to bring back. - expect( - vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.some((call) => String(call[1]).endsWith('orcad-active.json')) - ).toBe(false) - }) - - it('restores the rescue and the newer build after a failed target when no terminal started', async () => { - const log: string[] = [] - scriptHost(log, { targetReady: false }) - const result = await rollbackOrcad(options({ terminalsStartedSince: async () => 0 })) - expect(result.outcome === 'failed' && result.reason).toContain('is serving again') - expect(log.slice(-3)).toEqual(['compare', 'restore-rescue', `launch:${ACTIVE}`]) + expect(vi.mocked(writeAtomicOrcadRemoteRecord)).not.toHaveBeenCalled() }) it('records the rollback only after the target answers healthy', async () => { diff --git a/src/main/ssh/orcad-remote-rollback.ts b/src/main/ssh/orcad-remote-rollback.ts index 4640a6079bc3..09dc885d5ae1 100644 --- a/src/main/ssh/orcad-remote-rollback.ts +++ b/src/main/ssh/orcad-remote-rollback.ts @@ -8,27 +8,39 @@ * build cannot be shown to read the result. Rollback therefore restores the pre-activation * snapshot, and refuses when restoring it would orphan work (`assessOrcadRollback`). * - * The order is the whole safety argument: stop, rescue, restore, then start. Restoring under - * a running orcad would replace the store beneath a process holding it open, and starting - * before restoring would let the old build migrate the new build's state. The rescue copy of - * the newer state, and the journal under the activation fence, make each step undoable. + * The order below is the whole safety argument: stop, then restore, then start. Restoring + * under a running orcad would replace the store beneath a process holding it open, and + * starting before restoring would let the old build migrate the new build's state — the + * failure this is meant to avoid, arrived at from the other side. */ import type { SshConnection } from './ssh-connection' -import type { OrcadActivationRecord } from './orcad-activation-record' -import type { OrcadTerminalCensus } from './orcad-update-plan' -import type { OrcadActivationVerdict } from './orcad-activation-gate' -import type { OrcadDaemonProtocolFacts } from './orcad-daemon-protocol-crossing' -import { readOrcadActivationRecord } from './orcad-activation-record-store' -import { sameOrcadActivationRecord } from './orcad-activation-transaction' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { assertPosixOrcadHost } from './orcad-remote-host-support' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { computeRemoteInstallDir } from './ssh-relay-versioned-install' +import { RELAY_REMOTE_DIR } from './relay-protocol' import { - resolveOrcadActivationReadinessTimeout, - withOrcadActivationLock -} from './orcad-activation-lock' -import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' -import { rollbackOrcadLocked } from './orcad-rollback-transition' + ORCAD_STATE_SNAPSHOT_DIR, + withRolledBackVersion, + type OrcadActivationRecord +} from './orcad-activation-record' +import { assessOrcadRollback, type OrcadTerminalCensus } from './orcad-update-plan' +import { evaluateOrcadActivation, type OrcadActivationVerdict } from './orcad-activation-gate' +import { ORCAD_LOG_FILENAME } from './orcad-remote-launch' +import { launchOrcadAndAwaitReadiness } from './orcad-remote-runtime-control' +import { + newestStateMtimeCommand, + parseNewestStateMtimeSeconds, + parseOrcadSnapshotRestore, + probeOrcadStateSnapshotCommand, + restoreOrcadStateSnapshotCommand +} from './orcad-state-snapshot' +import { + orcadStopFreedTheHost, + parseOrcadStopOutcome, + stopOrcadCommand +} from './orcad-remote-process-control' +import { writeOrcadActivationRecord } from './orcad-activation-record-store' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' export type OrcadRollbackOptions = { conn: SshConnection @@ -42,10 +54,6 @@ export type OrcadRollbackOptions = { census: OrcadTerminalCensus /** Expected build hash of the rollback target, from the client's copy of those bytes. */ targetBuildHash: string - /** The rollback target's daemon protocol facts, from the same copy. */ - targetDaemonProtocol: OrcadDaemonProtocolFacts - /** A fresh census for a failed target that changed state; see `orcad-incumbent-recovery.ts`. */ - terminalsStartedSince?: (since: string) => Promise readinessTimeoutMs?: number now?: () => Date sleep?: (ms: number) => Promise @@ -57,34 +65,148 @@ export type OrcadRollbackResult = | { outcome: 'refused'; code: string; reason: string } | { outcome: 'failed'; code: string; reason: string } -export async function rollbackOrcad(input: OrcadRollbackOptions): Promise { - assertPosixOrcadHost(input.host) - const options = { - ...input, - readinessTimeoutMs: resolveOrcadActivationReadinessTimeout( - input.readinessTimeoutMs, - ORCAD_STARTUP_READINESS_TIMEOUT_MS - ) +const STOP_WAIT_SECONDS = 20 + +function exec(options: OrcadRollbackOptions, command: string): Promise { + return execCommand(options.conn, command, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + }) +} + +function snapshotDirPath(options: OrcadRollbackOptions, dirName: string): string { + return joinRemotePath( + options.host, + options.remoteHome, + RELAY_REMOTE_DIR, + ORCAD_STATE_SNAPSHOT_DIR, + dirName + ) +} + +/** Has the store been written since activation? `null` when it cannot be established. */ +async function readStateWritesSinceActivation( + options: OrcadRollbackOptions +): Promise { + if (!options.record.activatedAt) { + return null + } + const activatedAtSeconds = Math.floor(Date.parse(options.record.activatedAt) / 1000) + if (!Number.isFinite(activatedAtSeconds)) { + return null + } + const newest = parseNewestStateMtimeSeconds( + await exec(options, newestStateMtimeCommand(options.host, options.userDataDir)).catch(() => '') + ) + return newest === null ? null : newest >= activatedAtSeconds +} + +export async function rollbackOrcad(options: OrcadRollbackOptions): Promise { + const now = options.now ?? ((): Date => new Date()) + const snapshotPresent = options.record.snapshot + ? ( + await exec( + options, + probeOrcadStateSnapshotCommand( + options.host, + snapshotDirPath(options, options.record.snapshot.dirName) + ) + ).catch(() => 'ABSENT') + ).trim() === 'PRESENT' + : false + + const safety = assessOrcadRollback({ + record: options.record, + snapshotPresent, + census: options.census, + stateWritesSinceActivation: await readStateWritesSinceActivation(options) + }) + if (safety.safety === 'unsafe') { + return { outcome: 'refused', code: safety.code, reason: safety.reason } } - return withOrcadActivationLock(options, async (lock) => { - if (await readOrcadActivationTransaction(options)) { - lock.retain() + + if (options.record.active) { + const outgoingDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + options.record.active + ) + const stopped = parseOrcadStopOutcome( + await exec( + options, + stopOrcadCommand(options.host, outgoingDir, { + waitSeconds: STOP_WAIT_SECONDS, + nodePath: options.nodePath + }) + ) + ) + if (!orcadStopFreedTheHost(stopped)) { return { - outcome: 'refused', - code: 'orcad_activation_recovery_required', + outcome: 'failed', + code: 'orcad_rollback_stop_incomplete', reason: - 'An earlier activation on this host was interrupted. Recover it before rolling back.' + `Could not verify that orcad ${options.record.active} exited (${stopped}). ` + + 'Nothing was restored. Orca requires matching runtime readiness before signaling ' + + 'an incumbent and confirmed exit before replacing its state.' } } - if (!sameOrcadActivationRecord(await readOrcadActivationRecord(options), options.record)) { - return { - outcome: 'refused', - code: 'orcad_rollback_record_changed', - reason: - 'The host activation record changed while this rollback was waiting. Refresh the ' + - 'host state and review the new rollback target before trying again.' - } + } + + // Why between stop and start: the store must be replaced while no orcad holds it, and + // before the older build gets a chance to migrate the newer build's state. + const restored = parseOrcadSnapshotRestore( + await exec( + options, + restoreOrcadStateSnapshotCommand( + options.host, + options.userDataDir, + // Guarded by `assessOrcadRollback`: `unsafe` covers a missing snapshot. + snapshotDirPath(options, options.record.snapshot?.dirName ?? '') + ) + ).catch(() => 'FAILED') + ) + if (restored !== 'restored') { + return { + outcome: 'failed', + code: 'orcad_rollback_restore_failed', + reason: + `The pre-activation snapshot could not be restored (${restored}). orcad is stopped and ` + + 'the data root may be partially replaced. Do NOT start the older build against it; ' + + `re-deploy ${options.record.active ?? 'the newer version'}, which can read what is there.` } - return rollbackOrcadLocked(options, lock) + } + + const targetDir = computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, safety.target) + const parsed = await launchOrcadAndAwaitReadiness(options, { + remoteInstallDir: targetDir, + nodePath: options.nodePath, + fullVersion: safety.target, + userDataDir: options.userDataDir, + bindHost: options.bindHost, + port: options.port }) + const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, { + buildHash: options.targetBuildHash, + fullVersion: safety.target + }) + if (verdict.decision === 'reject') { + return { + outcome: 'failed', + code: verdict.code, + reason: + `The rollback target ${safety.target} did not come up healthy: ${verdict.reason} The ` + + `store has been restored to its pre-activation state. Its stderr is at ` + + `${joinRemotePath(options.host, targetDir, ORCAD_LOG_FILENAME)}.` + } + } + + // Why the record is written last: until the target is proven serving, `active` still names + // the version an operator would need to bring back, and `previous` still names this target. + await writeOrcadActivationRecord(options, withRolledBackVersion(options.record, now())) + return { + outcome: 'rolled-back', + target: safety.target, + discarded: safety.safety === 'lossy' ? safety.discards : [], + verdict + } } diff --git a/src/main/ssh/orcad-rollback-transition.ts b/src/main/ssh/orcad-rollback-transition.ts deleted file mode 100644 index ce5fe39f34b5..000000000000 --- a/src/main/ssh/orcad-rollback-transition.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** The locked, journaled half of `rollbackOrcad`. */ -import { randomUUID } from 'node:crypto' -import type { OrcadRollbackOptions, OrcadRollbackResult } from './orcad-remote-rollback' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { withRolledBackVersion } from './orcad-activation-record' -import { writeOrcadActivationRecord } from './orcad-activation-record-store' -import { evaluateOrcadActivation } from './orcad-activation-gate' -import { assessOrcadRollback } from './orcad-update-plan' -import { ORCAD_LOG_FILENAME, type OrcadReadinessParse } from './orcad-remote-launch' -import { - captureOrcadStateSnapshotCommand, - newestStateMtimeCommand, - orcadRollbackRescueDirName, - parseNewestStateMtimeSeconds, - parseOrcadSnapshotCapture, - parseOrcadSnapshotPresence, - parseOrcadSnapshotRestore, - probeOrcadStateSnapshotCommand, - restoreOrcadStateSnapshotCommand -} from './orcad-state-snapshot' -import { orcadStopFreedTheHost } from './orcad-remote-process-control' -import { joinRemotePath } from './ssh-remote-platform' -import type { OrcadActivationLockControl } from './orcad-activation-lock' -import type { OrcadRollbackTransaction } from './orcad-activation-transaction' -import { - createOrcadRollbackTransaction, - withOrcadRollbackPhase, - withOrcadRollbackRescue -} from './orcad-activation-transaction-transitions' -import { writeOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { - execOrcadRemote, - launchOrcadAndAwaitReadiness, - withoutAbortSignal -} from './orcad-remote-runtime-control' -import { - orcadSlotDir, - resolveOrcadSlotIdentity, - stopOrcadSlot, - ORCAD_SLOT_STOP_WAIT_SECONDS, - type OrcadSlotIdentity -} from './orcad-recovery-slot' -import { orcadSnapshotPath, recoverOrcadIncumbent } from './orcad-incumbent-recovery' - -/** A confirmed failure reads as no answer; an unconfirmed one propagates and keeps the fence. */ -async function execOrEmpty(options: OrcadRollbackOptions, command: string): Promise { - return execOrcadRemote(options, command).catch((error: unknown) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return '' - }) -} - -async function stateWritesSinceActivation(options: OrcadRollbackOptions): Promise { - const activatedAtSeconds = Math.floor(Date.parse(options.record.activatedAt ?? '') / 1000) - if (!Number.isFinite(activatedAtSeconds)) { - return null - } - const newest = parseNewestStateMtimeSeconds( - await execOrEmpty(options, newestStateMtimeCommand(options.host, options.userDataDir)) - ) - return newest === null ? null : newest >= activatedAtSeconds -} - -export async function rollbackOrcadLocked( - options: OrcadRollbackOptions, - lock: OrcadActivationLockControl -): Promise { - const now = options.now ?? ((): Date => new Date()) - const snapshot = options.record.snapshot - const presence = snapshot - ? parseOrcadSnapshotPresence( - await execOrEmpty( - options, - probeOrcadStateSnapshotCommand(options.host, orcadSnapshotPath(options, snapshot.dirName)) - ) - ) - : 'absent' - const safety = assessOrcadRollback({ - record: options.record, - snapshotPresent: presence === 'unverifiable' ? null : presence === 'present', - census: options.census, - targetDaemonProtocol: options.targetDaemonProtocol, - stateWritesSinceActivation: await stateWritesSinceActivation(options) - }) - if (safety.safety === 'unsafe') { - return { outcome: 'refused', code: safety.code, reason: safety.reason } - } - if (!snapshot || !options.record.active) { - return { - outcome: 'refused', - code: 'orcad_rollback_no_active', - reason: 'No orcad version is active on this host, so there is nothing to roll back from.' - } - } - let incumbent: OrcadSlotIdentity - try { - incumbent = await resolveOrcadSlotIdentity(options, options.record.active) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return { - outcome: 'refused', - code: 'orcad_rollback_active_identity_unverifiable', - reason: `The active orcad identity could not be verified: ${errorMessage(error)} Nothing was changed.` - } - } - - const startedAt = now() - let transaction: OrcadRollbackTransaction = createOrcadRollbackTransaction({ - transactionId: randomUUID(), - incumbentVersion: incumbent.version, - targetVersion: safety.target, - recordBefore: options.record, - recordAfter: withRolledBackVersion(options.record, startedAt), - rescueDirName: orcadRollbackRescueDirName(incumbent.version, startedAt.getTime()), - now: startedAt - }) - await writeOrcadActivationTransaction(options, transaction) - lock.retainOnError() - - const stopped = await stopOrcadSlot(options, incumbent.remoteDir, false) - if (!orcadStopFreedTheHost(stopped)) { - if (stopped === 'still-running') { - lock.retain() - } - return { - outcome: 'failed', - code: 'orcad_rollback_stop_incomplete', - reason: - `Could not verify that orcad ${incumbent.version} exited within ` + - `${ORCAD_SLOT_STOP_WAIT_SECONDS}s (${stopped}). Nothing was restored. Orca requires ` + - 'matching runtime readiness before signaling an incumbent and confirmed exit before ' + - 'replacing its state.' - } - } - transaction = withOrcadRollbackPhase(transaction, 'incumbent-stopped', now()) - await writeOrcadActivationTransaction(options, transaction) - - const rescue = parseOrcadSnapshotCapture( - await execOrEmpty( - options, - captureOrcadStateSnapshotCommand( - options.host, - options.userDataDir, - orcadSnapshotPath(options, transaction.rescue.dirName) - ) - ) - ) - if (rescue === 'failed') { - const recovered = await putIncumbentBack(options, lock, transaction, incumbent, null) - return { - outcome: 'failed', - code: 'orcad_rollback_rescue_snapshot_failed', - reason: - 'The current state could not be preserved in a rescue snapshot, so the data root was ' + - `not replaced. ${recovered}` - } - } - transaction = withOrcadRollbackRescue(transaction, rescue, now()) - await writeOrcadActivationTransaction(options, transaction) - - // Why between stop and start: the older build must never load the newer build's state. - const restored = parseOrcadSnapshotRestore( - await execOrEmpty( - options, - restoreOrcadStateSnapshotCommand( - options.host, - options.userDataDir, - orcadSnapshotPath(options, snapshot.dirName) - ) - ) - ) - if (restored !== 'restored') { - const recovered = await putIncumbentBack(options, lock, transaction, incumbent, null) - return { - outcome: 'failed', - code: 'orcad_rollback_restore_failed', - reason: `The pre-activation snapshot could not be restored (${restored}). ${recovered}` - } - } - transaction = withOrcadRollbackPhase(transaction, 'rollback-state-restored', now()) - await writeOrcadActivationTransaction(options, transaction) - - const targetDir = orcadSlotDir(options, safety.target) - let parsed: OrcadReadinessParse | null = null - let launchError: unknown - try { - parsed = await launchOrcadAndAwaitReadiness(options, { - remoteInstallDir: targetDir, - nodePath: options.nodePath, - fullVersion: safety.target, - userDataDir: options.userDataDir, - bindHost: options.bindHost, - port: options.port - }) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - launchError = error - } - const verdict = evaluateOrcadActivation(parsed?.state === 'ready' ? parsed.readiness : null, { - buildHash: options.targetBuildHash, - fullVersion: safety.target - }) - if (verdict.decision === 'reject') { - const reason = - launchError === undefined - ? verdict.reason - : `It failed while starting: ${errorMessage(launchError)}` - const targetStop = await stopOrcadSlot(withoutAbortSignal(options), targetDir, true).catch( - (error: unknown) => `unverifiable: ${errorMessage(error)}` - ) - const recovered = - targetStop === 'stopped' || targetStop === 'already-exited' - ? await putIncumbentBack(options, lock, transaction, incumbent, safety.target) - : retainedAfterTargetStop(lock, targetStop) - return { - outcome: 'failed', - code: launchError === undefined ? verdict.code : 'orcad_rollback_target_launch_failed', - reason: - `The rollback target ${safety.target} did not come up healthy: ${reason} ${recovered} ` + - `Its stderr is at ${joinRemotePath(options.host, targetDir, ORCAD_LOG_FILENAME)}.` - } - } - - // The record is written last: until the target is proven serving, `active` still names the - // version an operator would need to bring back. - transaction = withOrcadRollbackPhase(transaction, 'target-ready', now()) - await writeOrcadActivationTransaction(options, transaction) - await writeOrcadActivationRecord(options, transaction.recordAfter) - return { - outcome: 'rolled-back', - target: safety.target, - discarded: safety.safety === 'lossy' ? safety.discards : [], - verdict - } -} - -function retainedAfterTargetStop(lock: OrcadActivationLockControl, stopped: string): string { - lock.retain() - return ( - `The target could not be confirmed stopped (${stopped}), so the rescue snapshot was not ` + - 'restored over state it may still own. This host requires recovery.' - ) -} - -/** Restores the rescued state when it was replaced, then restarts the incumbent. */ -async function putIncumbentBack( - options: OrcadRollbackOptions, - lock: OrcadActivationLockControl, - transaction: OrcadRollbackTransaction, - incumbent: OrcadSlotIdentity, - launchedVersion: string | null -): Promise { - try { - const recovery = await recoverOrcadIncumbent(withoutAbortSignal(options), { - transactionStartedAt: transaction.startedAt, - launchedVersion, - incumbent, - restoreState: transaction.rescue.state === 'pending' ? null : transaction.rescue, - slotsProvenExited: true - }) - if (recovery.outcome === 'refused') { - lock.retain() - return recovery.reason - } - lock.recovered() - return `orcad ${incumbent.version} was restored and is serving again.` - } catch (error) { - lock.retain() - return `Restoring orcad ${incumbent.version} failed: ${errorMessage(error)} This host requires recovery.` - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-state-snapshot-shell.test.ts b/src/main/ssh/orcad-state-snapshot-shell.test.ts deleted file mode 100644 index ff6c263a977f..000000000000 --- a/src/main/ssh/orcad-state-snapshot-shell.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' -import { - captureOrcadStateSnapshotCommand, - clearOrcadStateSnapshotMembersCommand, - parseOrcadSnapshotCapture, - parseOrcadSnapshotRestore, - restoreOrcadStateSnapshotCommand -} from './orcad-state-snapshot' -import { getRemoteHostPlatform } from './ssh-remote-platform' - -const posix = getRemoteHostPlatform('linux-x64') - -async function sh(command: string): Promise { - const result = await runProcess({ program: '/bin/sh', args: ['-c', command] }) - return result.stdout -} - -describe.skipIf(process.platform === 'win32')('snapshot commands on a real shell', () => { - let base: string - let root: string - let snapshot: string - - beforeEach(() => { - base = mkdtempSync(join(tmpdir(), 'orcad-snapshot-shell-')) - root = join(base, 'root') - snapshot = join(base, 'snapshots', 'pre-1') - mkdirSync(join(root, 'profiles'), { recursive: true }) - mkdirSync(join(root, 'daemon'), { recursive: true }) - writeFileSync(join(root, 'profiles', 'p.json'), 'old') - writeFileSync(join(root, 'daemon', 'token'), 'live-daemon') - }) - - afterEach(() => { - rmSync(base, { recursive: true, force: true }) - }) - - it('restores members, drops files the newer build added, and leaves the daemon alone', async () => { - expect( - parseOrcadSnapshotCapture(await sh(captureOrcadStateSnapshotCommand(posix, root, snapshot))) - ).toBe('captured') - writeFileSync(join(root, 'profiles', 'p.json'), 'migrated') - writeFileSync(join(root, 'profiles', 'added.json'), 'new') - writeFileSync(join(root, 'daemon', 'token'), 'rotated') - - expect( - parseOrcadSnapshotRestore(await sh(restoreOrcadStateSnapshotCommand(posix, root, snapshot))) - ).toBe('restored') - expect(readFileSync(join(root, 'profiles', 'p.json'), 'utf8')).toBe('old') - expect(existsSync(join(root, 'profiles', 'added.json'))).toBe(false) - expect(readFileSync(join(root, 'daemon', 'token'), 'utf8')).toBe('rotated') - expect(existsSync(join(root, '.orcad-state-restore-stage'))).toBe(false) - }) - - it('keeps live state when the archive cannot be extracted', async () => { - await sh(captureOrcadStateSnapshotCommand(posix, root, snapshot)) - writeFileSync(join(snapshot, 'state.tar'), 'not a tar archive') - writeFileSync(join(root, 'profiles', 'p.json'), 'current') - - expect( - parseOrcadSnapshotRestore(await sh(restoreOrcadStateSnapshotCommand(posix, root, snapshot))) - ).toBe('failed') - expect(readFileSync(join(root, 'profiles', 'p.json'), 'utf8')).toBe('current') - }) - - it('reruns cleanly after a restore interrupted between removal and replacement', async () => { - await sh(captureOrcadStateSnapshotCommand(posix, root, snapshot)) - // Simulates a crash that left the stage behind and the live members already removed. - mkdirSync(join(root, '.orcad-state-restore-stage', 'profiles'), { recursive: true }) - rmSync(join(root, 'profiles'), { recursive: true }) - - expect( - parseOrcadSnapshotRestore(await sh(restoreOrcadStateSnapshotCommand(posix, root, snapshot))) - ).toBe('restored') - expect(readFileSync(join(root, 'profiles', 'p.json'), 'utf8')).toBe('old') - }) - - it('clears only the snapshot members for a root that started empty', async () => { - expect( - parseOrcadSnapshotRestore(await sh(clearOrcadStateSnapshotMembersCommand(posix, root))) - ).toBe('restored') - expect(existsSync(join(root, 'profiles'))).toBe(false) - expect(readFileSync(join(root, 'daemon', 'token'), 'utf8')).toBe('live-daemon') - }) -}) diff --git a/src/main/ssh/orcad-state-snapshot.test.ts b/src/main/ssh/orcad-state-snapshot.test.ts index c8740507698f..f660e95e5a42 100644 --- a/src/main/ssh/orcad-state-snapshot.test.ts +++ b/src/main/ssh/orcad-state-snapshot.test.ts @@ -4,15 +4,12 @@ import { ORCAD_SNAPSHOT_EXCLUDED, ORCAD_SNAPSHOT_MEMBERS, captureOrcadStateSnapshotCommand, - clearOrcadStateSnapshotMembersCommand, compareOrcadStateSnapshotCommand, newestStateMtimeCommand, orcadSnapshotDirName, parseNewestStateMtimeSeconds, parseOrcadSnapshotCapture, - parseOrcadSnapshotPresence, parseOrcadSnapshotRestore, - probeOrcadStateSnapshotCommand, restoreOrcadStateSnapshotCommand } from './orcad-state-snapshot' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -63,11 +60,9 @@ describe('capturing the pre-activation snapshot', () => { }) describe('restoring the snapshot', () => { - it('proves the archive extracts before clearing the live members', () => { + it('clears the members before extracting, so files the new build added do not survive', () => { const command = restoreOrcadStateSnapshotCommand(posix, ROOT, SNAP) - const extract = command.indexOf(`tar -C '${ROOT}/.orcad-state-restore-stage'`) - expect(extract).toBeGreaterThan(-1) - expect(extract).toBeLessThan(command.indexOf(`rm -rf '${ROOT}'/'profiles'`)) + expect(command.indexOf('rm -rf')).toBeLessThan(command.indexOf('tar -C')) }) it('reports a missing archive instead of extracting nothing and claiming success', () => { @@ -76,15 +71,6 @@ describe('restoring the snapshot', () => { expect(parseOrcadSnapshotRestore('RESTORED')).toBe('restored') expect(parseOrcadSnapshotRestore('FAILED')).toBe('failed') }) - - it.each([ - ['PRESENT', 'present'], - ['ABSENT', 'absent'], - ['', 'unverifiable'], - ['bash: tar: command not found', 'unverifiable'] - ])('reads snapshot presence %j as %s, never treating silence as absence', (out, expected) => { - expect(parseOrcadSnapshotPresence(out)).toBe(expected) - }) }) describe('detecting writes since activation', () => { @@ -108,8 +94,6 @@ describe('Windows hosts', () => { it.each([ ['capture', () => captureOrcadStateSnapshotCommand(windows, ROOT, SNAP)], ['restore', () => restoreOrcadStateSnapshotCommand(windows, ROOT, SNAP)], - ['clear', () => clearOrcadStateSnapshotMembersCommand(windows, ROOT)], - ['presence', () => probeOrcadStateSnapshotCommand(windows, SNAP)], ['compare', () => compareOrcadStateSnapshotCommand(windows, ROOT, SNAP)], ['mtime', () => newestStateMtimeCommand(windows, ROOT)] ])('refuses %s rather than emitting a POSIX command', (_label, build) => { diff --git a/src/main/ssh/orcad-state-snapshot.ts b/src/main/ssh/orcad-state-snapshot.ts index a5ce56d7339f..7d0622754424 100644 --- a/src/main/ssh/orcad-state-snapshot.ts +++ b/src/main/ssh/orcad-state-snapshot.ts @@ -47,8 +47,6 @@ function assertPlainMemberName(member: string): string { return member } -const RESTORE_STAGE_DIRNAME = '.orcad-state-restore-stage' - function noSymlinkedStateCommand(path: string): string { return `links=$(find ${path} -type l -print) && [ -z "$links" ]` } @@ -59,11 +57,6 @@ export function orcadSnapshotDirName(fullVersion: string, takenAtMs: number): st return `pre-${fullVersion}-${takenAtMs}` } -/** The newer build's state, kept so an interrupted rollback can put it back. */ -export function orcadRollbackRescueDirName(fullVersion: string, takenAtMs: number): string { - return `rollback-rescue-${fullVersion}-${takenAtMs}` -} - /** * Capture the snapshot, or report why there is nothing to capture. * @@ -125,25 +118,12 @@ export function probeOrcadStateSnapshotCommand( return `test -f ${archive} && echo PRESENT || echo ABSENT` } -export type OrcadSnapshotPresence = 'present' | 'absent' | 'unverifiable' - -/** A lost probe is `unverifiable`, never `absent`. */ -export function parseOrcadSnapshotPresence(output: string): OrcadSnapshotPresence { - const value = output.trim().split('\n').pop()?.trim() - if (value === 'PRESENT') { - return 'present' - } - return value === 'ABSENT' ? 'absent' : 'unverifiable' -} - /** * Restore the snapshot over the data root. * - * Three things make this safe to run: the archive is extracted into a stage first, so an - * unreadable archive fails before live state is touched; the members are then removed before - * the staged copies move in (so a file the new version added is gone rather than - * half-shadowed); and neither step can reach `/daemon`, because the member list never - * names it. + * Two things make this safe to run: the members are removed before extraction (so a file the + * new version added is gone rather than half-shadowed), and neither the removal nor the + * extraction can reach `/daemon`, because the member list never names it. * * The caller must have stopped orcad first. This does not check — it cannot, from a shell — * so `orcad-remote-deploy.ts` owns that ordering. @@ -156,43 +136,17 @@ export function restoreOrcadStateSnapshotCommand( assertPosixHost(host) const root = shellEscape(userDataDir) const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) - const stage = shellEscape(joinRemotePath(host, userDataDir, RESTORE_STAGE_DIRNAME)) const removals = ORCAD_SNAPSHOT_MEMBERS.map( - (member) => `rm -rf ${root}/${shellEscape(member)}` - ).join(' && ') - const replacements = ORCAD_SNAPSHOT_MEMBERS.map((member) => { - const name = shellEscape(member) - return `if [ -e ${stage}/${name} ]; then mv ${stage}/${name} ${root}/${name}; fi` - }).join(' && ') - const stagedMemberChecks = ORCAD_SNAPSHOT_MEMBERS.map( - (member) => `[ -e ${stage}/${shellEscape(member)} ]` - ).join(' || ') + (member) => `rm -rf ${root}/${shellEscape(member)};` + ).join(' ') return [ `test -f ${archive} || { echo MISSING; exit 0; };`, - 'umask 077;', `test -d ${root} || mkdir -p ${root};`, - // Re-extracting from the intact archive makes an interrupted restore safe to rerun. - `rm -rf ${stage}; mkdir -p ${stage} || { echo FAILED; exit 0; };`, - // Extraction proves every archived byte is readable before live state is removed. - `tar -C ${stage} -xf ${archive} 2>/dev/null || { rm -rf ${stage}; echo FAILED; exit 0; };`, - `${stagedMemberChecks} || { rm -rf ${stage}; echo FAILED; exit 0; };`, - `if ${removals} && ${replacements}; then rm -rf ${stage}; echo RESTORED; else echo FAILED; fi` + removals, + `tar -C ${root} -xf ${archive} && echo RESTORED || echo FAILED` ].join(' ') } -/** Restore an originally empty state root after a candidate populated it. */ -export function clearOrcadStateSnapshotMembersCommand( - host: RemoteHostPlatform, - userDataDir: string -): string { - assertPosixHost(host) - const root = shellEscape(userDataDir) - const removals = ORCAD_SNAPSHOT_MEMBERS.map( - (member) => `rm -rf ${root}/${shellEscape(member)}` - ).join(' && ') - return `test -d ${root} || mkdir -p ${root}; if ${removals}; then echo RESTORED; else echo FAILED; fi` -} - export type OrcadSnapshotRestore = 'restored' | 'missing' | 'failed' export function parseOrcadSnapshotRestore(output: string): OrcadSnapshotRestore { diff --git a/src/main/ssh/orcad-update-plan.test.ts b/src/main/ssh/orcad-update-plan.test.ts index edd747964091..5d90c597e3c2 100644 --- a/src/main/ssh/orcad-update-plan.test.ts +++ b/src/main/ssh/orcad-update-plan.test.ts @@ -7,8 +7,6 @@ import { type OrcadStateSnapshot } from './orcad-activation-record' -const PROTOCOL = { protocolVersion: 3, previousProtocolVersions: [1, 2] } - const SNAPSHOT: OrcadStateSnapshot = { dirName: 'pre-0.2.0+bb01-1000', takenBeforeVersion: '0.2.0+bb01', @@ -30,20 +28,18 @@ function record(overrides: Partial = {}): OrcadActivation describe('planOrcadUpdate', () => { it('does nothing when the candidate is already active', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.2.0+bb01', - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } + census: { liveSessions: 0, startedSinceActivation: 0 } }) expect(plan).toMatchObject({ action: 'noop' }) }) it('defers rather than restarting a host with live terminals', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: 3, startedSinceActivation: 1, daemonProtocolVersion: 3 } + census: { liveSessions: 3, startedSinceActivation: 1 } }) expect(plan).toMatchObject({ action: 'defer', code: 'orcad_update_terminals_running' }) expect(plan.action === 'defer' && plan.reason).toContain('would not kill them') @@ -51,10 +47,9 @@ describe('planOrcadUpdate', () => { it('defers when the session count cannot be established', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: 3 } + census: { liveSessions: null, startedSinceActivation: null } }) expect(plan).toMatchObject({ action: 'defer', @@ -64,10 +59,9 @@ describe('planOrcadUpdate', () => { it('plans a forced update with an unknown census as if terminals were live', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: 3 }, + census: { liveSessions: null, startedSinceActivation: null }, force: true }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) @@ -75,10 +69,9 @@ describe('planOrcadUpdate', () => { it('carries the daemon across a forced update with live terminals', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: 2, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 2, startedSinceActivation: 0 }, force: true }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) @@ -86,10 +79,9 @@ describe('planOrcadUpdate', () => { it('replaces the daemon only when nothing is running under it', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } + census: { liveSessions: 0, startedSinceActivation: 0 } }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: false }) }) @@ -98,10 +90,9 @@ describe('planOrcadUpdate', () => { describe('assessOrcadRollback', () => { it('is clean when the snapshot is intact and nothing happened since activation', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'clean', target: '0.1.0+aa01' }) @@ -109,10 +100,9 @@ describe('assessOrcadRollback', () => { it('is lossy, and names what goes, once the store has been written since activation', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 1, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 1, startedSinceActivation: 0 }, stateWritesSinceActivation: true }) expect(safety).toMatchObject({ safety: 'lossy', target: '0.1.0+aa01' }) @@ -121,10 +111,9 @@ describe('assessOrcadRollback', () => { it('treats an unreadable store mtime as writes, not as a clean rollback', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: null }) expect(safety).toMatchObject({ safety: 'lossy' }) @@ -133,10 +122,9 @@ describe('assessOrcadRollback', () => { // The point past which rollback is unsafe: the first terminal created after activation. it('refuses once a terminal started after activation, because restoring would orphan it', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 4, startedSinceActivation: 1, daemonProtocolVersion: 3 }, + census: { liveSessions: 4, startedSinceActivation: 1 }, stateWritesSinceActivation: true }) expect(safety).toMatchObject({ @@ -148,10 +136,9 @@ describe('assessOrcadRollback', () => { it('refuses when the snapshot the record names is gone from the host', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: false, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_missing' }) @@ -160,10 +147,9 @@ describe('assessOrcadRollback', () => { it('refuses when no snapshot was ever recorded', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record({ snapshot: null }), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_missing' }) @@ -171,10 +157,9 @@ describe('assessOrcadRollback', () => { it('refuses when the post-activation session count is unverifiable', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 2, startedSinceActivation: null, daemonProtocolVersion: 3 }, + census: { liveSessions: 2, startedSinceActivation: null }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_census_unavailable' }) @@ -182,83 +167,11 @@ describe('assessOrcadRollback', () => { it('refuses when there is no previous version to go back to', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record({ previous: null }), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_no_target' }) }) }) - -describe('D7 daemon protocol crossing', () => { - const census = (daemonProtocolVersion: number | null, liveSessions: number | null = 2) => ({ - liveSessions, - startedSinceActivation: 0, - daemonProtocolVersion - }) - - it.each([false, true])( - 'defers an update that would strand live terminals (force %s)', - (force) => { - const plan = planOrcadUpdate({ - record: record(), - candidateVersion: '0.3.0+cc01', - candidateDaemonProtocol: PROTOCOL, - census: census(4), - force - }) - expect(plan).toMatchObject({ action: 'defer', code: 'orcad_update_strands_live_terminals' }) - } - ) - - it('will not force past live terminals whose daemon protocol is unknown', () => { - const plan = planOrcadUpdate({ - record: record(), - candidateVersion: '0.3.0+cc01', - candidateDaemonProtocol: PROTOCOL, - census: census(null), - force: true - }) - expect(plan).toMatchObject({ - action: 'defer', - code: 'orcad_update_daemon_protocol_unverifiable' - }) - }) - - it('needs no protocol answer when no terminals are running', () => { - const plan = planOrcadUpdate({ - record: record(), - candidateVersion: '0.3.0+cc01', - candidateDaemonProtocol: PROTOCOL, - census: census(null, 0) - }) - expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: false }) - }) - - it.each([ - [4, 'orcad_rollback_strands_live_terminals'], - [null, 'orcad_rollback_daemon_protocol_unverifiable'] - ])('refuses a rollback when the daemon speaks %s', (daemonProtocolVersion, code) => { - const safety = assessOrcadRollback({ - record: record(), - snapshotPresent: true, - census: census(daemonProtocolVersion), - targetDaemonProtocol: PROTOCOL, - stateWritesSinceActivation: false - }) - expect(safety).toMatchObject({ safety: 'unsafe', code }) - }) - - it('does not read an unverifiable snapshot probe as a missing snapshot', () => { - const safety = assessOrcadRollback({ - record: record(), - snapshotPresent: null, - census: census(3, 0), - targetDaemonProtocol: PROTOCOL, - stateWritesSinceActivation: false - }) - expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_unverifiable' }) - }) -}) diff --git a/src/main/ssh/orcad-update-plan.ts b/src/main/ssh/orcad-update-plan.ts index e6c6e865d84f..2653956ebe51 100644 --- a/src/main/ssh/orcad-update-plan.ts +++ b/src/main/ssh/orcad-update-plan.ts @@ -19,10 +19,6 @@ * pre-activation snapshot rather than against a version comparison. */ import type { OrcadActivationRecord } from './orcad-activation-record' -import { - assessOrcadLiveDaemonCrossing, - type OrcadDaemonProtocolFacts -} from './orcad-daemon-protocol-crossing' export type OrcadTerminalCensus = { /** @@ -36,8 +32,6 @@ export type OrcadTerminalCensus = { * pre-activation snapshot does not describe. */ startedSinceActivation: number | null - /** Protocol of the daemon that owns those sessions; `null` when it did not answer. */ - daemonProtocolVersion: number | null } export type OrcadUpdateDecision = @@ -53,8 +47,6 @@ export type OrcadUpdateDecision = export type OrcadUpdateDeferCode = | 'orcad_update_terminals_running' | 'orcad_update_terminal_census_unavailable' - | 'orcad_update_strands_live_terminals' - | 'orcad_update_daemon_protocol_unverifiable' /** * Decide whether to restart orcad onto `candidateVersion`. @@ -68,8 +60,6 @@ export function planOrcadUpdate(input: { record: OrcadActivationRecord candidateVersion: string census: OrcadTerminalCensus - /** The candidate's daemon protocol and the older ones it can still attach to. */ - candidateDaemonProtocol: OrcadDaemonProtocolFacts force?: boolean }): OrcadUpdateDecision { if (input.record.active === input.candidateVersion) { @@ -78,29 +68,7 @@ export function planOrcadUpdate(input: { reason: `${input.candidateVersion} is already the active version; nothing to restart.` } } - const crossing = assessOrcadLiveDaemonCrossing(input.census, input.candidateDaemonProtocol) - // Why force cannot override: the operator can accept a mixed pair, not unreachable terminals. - if (crossing === 'strands-live-terminals') { - return { - action: 'defer', - code: 'orcad_update_strands_live_terminals', - reason: - `The live terminal daemon speaks protocol ${String(input.census.daemonProtocolVersion)}, ` + - `which orcad ${input.candidateVersion} cannot attach to. Restarting now would leave ` + - 'every running terminal unreachable. Update when no terminals are running.' - } - } const { liveSessions } = input.census - if (input.force && crossing === 'unverifiable') { - return { - action: 'defer', - code: 'orcad_update_daemon_protocol_unverifiable', - reason: - 'The terminal daemon did not report its protocol, so this update cannot show that ' + - `orcad ${input.candidateVersion} will reach the terminals it is forced past. Retry ` + - 'when the daemon answers.' - } - } if (liveSessions === null) { if (!input.force) { return { @@ -168,9 +136,6 @@ export type OrcadRollbackUnsafeCode = | 'orcad_rollback_snapshot_missing' | 'orcad_rollback_orphans_live_terminals' | 'orcad_rollback_census_unavailable' - | 'orcad_rollback_snapshot_unverifiable' - | 'orcad_rollback_strands_live_terminals' - | 'orcad_rollback_daemon_protocol_unverifiable' /** * How safe it is to switch back to `record.previous`. @@ -191,11 +156,9 @@ export type OrcadRollbackUnsafeCode = */ export function assessOrcadRollback(input: { record: OrcadActivationRecord - /** Whether the snapshot is still on the host; `null` means the probe was unverifiable. */ - snapshotPresent: boolean | null + /** Whether the snapshot named by the record is actually still on the host. */ + snapshotPresent: boolean census: OrcadTerminalCensus - /** The rollback target's daemon protocol facts, from the client's copy of its bytes. */ - targetDaemonProtocol: OrcadDaemonProtocolFacts /** * Whether the shared store has been written since activation, from its mtime against * `record.activatedAt`. `null` means unknown, which is treated as "yes" — claiming a @@ -213,15 +176,6 @@ export function assessOrcadRollback(input: { 'to. Deploy a known-good build instead.' } } - if (input.record.snapshot && input.snapshotPresent === null) { - return { - safety: 'unsafe', - code: 'orcad_rollback_snapshot_unverifiable', - reason: - 'The host did not give a trustworthy answer about the pre-activation snapshot. Retry ' + - 'when the host is reachable; loss of contact is not evidence the snapshot is gone.' - } - } if (!input.record.snapshot || !input.snapshotPresent) { return { safety: 'unsafe', @@ -255,26 +209,6 @@ export function assessOrcadRollback(input: { 'reattach. Close them (or let them exit) and roll back then.' } } - const crossing = assessOrcadLiveDaemonCrossing(input.census, input.targetDaemonProtocol) - if (crossing === 'unverifiable') { - return { - safety: 'unsafe', - code: 'orcad_rollback_daemon_protocol_unverifiable', - reason: - 'The terminal daemon did not report its protocol, so this rollback cannot show that ' + - `${target} would reach the terminals still running. Retry when the daemon answers.` - } - } - if (crossing === 'strands-live-terminals') { - return { - safety: 'unsafe', - code: 'orcad_rollback_strands_live_terminals', - reason: - `The live terminal daemon speaks protocol ${String(input.census.daemonProtocolVersion)}, ` + - `which ${target} does not list. Rolling back now would leave every running terminal ` + - 'unreachable. Roll back when no terminals are running, or deploy forward.' - } - } if (input.stateWritesSinceActivation === false) { return { safety: 'clean', diff --git a/src/main/ssh/ssh-relay-install-lock.test.ts b/src/main/ssh/ssh-relay-install-lock.test.ts index d17d5725f435..d70409def413 100644 --- a/src/main/ssh/ssh-relay-install-lock.test.ts +++ b/src/main/ssh/ssh-relay-install-lock.test.ts @@ -14,7 +14,7 @@ vi.mock('./ssh-relay-gc-claim', () => ({ })) import { execCommand } from './ssh-relay-deploy-helpers' -import { acquireInstallLock, RemoteInstallLockBusyError } from './ssh-relay-install-lock' +import { acquireInstallLock } from './ssh-relay-install-lock' import { getRemoteHostPlatform } from './ssh-remote-platform' describe('acquireInstallLock', () => { @@ -46,29 +46,4 @@ describe('acquireInstallLock', () => { ) ).rejects.toBe(termination) }) - - it.each([ - [true, 'OK'], - [false, 'BUSY'] - ])('steals a stale lock only when takeover is allowed (%s)', async (allow, expected) => { - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('.steal')) { - return 'OK' - } - return command.includes('mkdir -p') ? '' : 'BUSY' - }) - const acquired = acquireInstallLock( - {} as SshConnection, - '/home/u/.orca-remote/.orcad-activation-transaction', - getRemoteHostPlatform('linux-x64'), - { relayGcClaim: false, allowStaleTakeover: allow, waitTimeoutMs: 0 } - ).then( - () => 'OK', - (error: unknown) => (error instanceof RemoteInstallLockBusyError ? 'BUSY' : String(error)) - ) - expect(await acquired).toBe(expected) - expect( - vi.mocked(execCommand).mock.calls.some(([, command]) => command.includes('.steal')) - ).toBe(allow) - }) }) diff --git a/src/main/ssh/ssh-relay-install-lock.ts b/src/main/ssh/ssh-relay-install-lock.ts index ef570ecb2fe3..eaa759bcf9a4 100644 --- a/src/main/ssh/ssh-relay-install-lock.ts +++ b/src/main/ssh/ssh-relay-install-lock.ts @@ -32,16 +32,6 @@ export const INSTALL_LOCK_STALE_MS = 20 * 60_000 export const INSTALL_LOCK_STALE_SECONDS = INSTALL_LOCK_STALE_MS / 1000 const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') -export class RemoteInstallLockBusyError extends Error { - constructor(lockDir: string, timeoutMs: number) { - super( - `Could not acquire relay install lock at ${lockDir} after ${timeoutMs / 1000}s; ` + - 'another install is still in progress.' - ) - this.name = 'RemoteInstallLockBusyError' - } -} - function execHostCommand( conn: SshConnection, host: RemoteHostPlatform, @@ -87,16 +77,9 @@ export async function acquireInstallLock( lockName?: string /** False for a lock whose directory is not a relay version dir, so no GC claim can name it. */ relayGcClaim?: boolean - /** False when a held lock is a fence whose age cannot prove its owner's work is finished. */ - allowStaleTakeover?: boolean - waitTimeoutMs?: number } ): Promise { const lockDir = joinRemotePath(host, remoteRelayDir, options?.lockName ?? RELAY_INSTALL_LOCK_NAME) - const waitTimeoutMs = options?.waitTimeoutMs ?? INSTALL_LOCK_TIMEOUT_MS - if (!Number.isSafeInteger(waitTimeoutMs) || waitTimeoutMs < 0) { - throw new Error('Install lock wait timeout must be a non-negative integer.') - } const relayGcClaim = options?.relayGcClaim ?? true const isClaimed = (): Promise => relayGcClaim @@ -147,10 +130,7 @@ export async function acquireInstallLock( // A failed mkdir is lock contention; keep the connection-specific error // out of the user path until the bounded wait expires. } - if ( - options?.allowStaleTakeover !== false && - Date.now() - lastStaleCheckAt >= INSTALL_LOCK_STALE_RECHECK_MS - ) { + if (Date.now() - lastStaleCheckAt >= INSTALL_LOCK_STALE_RECHECK_MS) { lastStaleCheckAt = Date.now() // Why: recover an already-stale lock immediately, then keep checking in // case a fresh holder crosses the stale threshold while we are waiting. @@ -190,8 +170,12 @@ export async function acquireInstallLock( lastWaitLogAt = Date.now() console.info(`[ssh-relay] Waiting for install lock at ${lockDir}`) } - if (Date.now() - start >= waitTimeoutMs) { - throw new RemoteInstallLockBusyError(lockDir, waitTimeoutMs) + if (Date.now() - start >= INSTALL_LOCK_TIMEOUT_MS) { + throw new Error( + `Could not acquire relay install lock at ${lockDir} after ${ + INSTALL_LOCK_TIMEOUT_MS / 1000 + }s; another install is still in progress.` + ) } await waitForInstallLockPoll(options?.signal) } diff --git a/src/shared/runtime-bootstrap.ts b/src/shared/runtime-bootstrap.ts index ca8cd456b72e..993ab5e023d1 100644 --- a/src/shared/runtime-bootstrap.ts +++ b/src/shared/runtime-bootstrap.ts @@ -42,7 +42,7 @@ export function findTransport( return null } -export const PRIMARY_RUNTIME_METADATA_FILE = 'orca-runtime.json' +const PRIMARY_RUNTIME_METADATA_FILE = 'orca-runtime.json' export function getRuntimeMetadataPath(userDataPath: string): string { return join(userDataPath, PRIMARY_RUNTIME_METADATA_FILE) From 92602ac92c83dd40bee425633326249be9519865 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:03 -0700 Subject: [PATCH 08/26] Revert "feat(runtime): SSH access links for paired servers in a downgrade-safe sidecar (#16741 T5-1+T5-2) (#24420)" This reverts commit 99db2bfae415fe2e5199e7b559853d211bfdbe83. --- ...ntime-environment-identity-verification.ts | 47 --- .../runtime-environment-authority-binding.ts | 19 - ...ntime-environment-reconciliation-record.ts | 30 -- .../runtime-environment-sidecar.test.ts | 181 ---------- src/shared/runtime-environment-sidecar.ts | 192 ----------- ...ntime-environment-ssh-access-store.test.ts | 325 ------------------ .../runtime-environment-ssh-access-store.ts | 307 ----------------- src/shared/runtime-environment-store-file.ts | 109 ------ src/shared/runtime-environment-store.ts | 129 ++++--- .../runtime-environments-ssh-access.test.ts | 137 -------- src/shared/runtime-environments.ts | 111 +----- 11 files changed, 85 insertions(+), 1502 deletions(-) delete mode 100644 src/main/runtime/runtime-environment-identity-verification.ts delete mode 100644 src/shared/runtime-environment-authority-binding.ts delete mode 100644 src/shared/runtime-environment-reconciliation-record.ts delete mode 100644 src/shared/runtime-environment-sidecar.test.ts delete mode 100644 src/shared/runtime-environment-sidecar.ts delete mode 100644 src/shared/runtime-environment-ssh-access-store.test.ts delete mode 100644 src/shared/runtime-environment-ssh-access-store.ts delete mode 100644 src/shared/runtime-environment-store-file.ts delete mode 100644 src/shared/runtime-environments-ssh-access.test.ts diff --git a/src/main/runtime/runtime-environment-identity-verification.ts b/src/main/runtime/runtime-environment-identity-verification.ts deleted file mode 100644 index b23f06587060..000000000000 --- a/src/main/runtime/runtime-environment-identity-verification.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - getPreferredPairingOffer, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { verifyRemotePairingRuntimeStatus } from '../../shared/remote-pairing-verification' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' -import type { RuntimeStatus } from '../../shared/runtime-types' - -export async function verifyRuntimeEnvironmentIdentity( - environment: KnownRuntimeEnvironment, - options: { endpoint?: string; signal?: AbortSignal } = {} -) { - options.signal?.throwIfAborted() - const verifiedPairing = { - ...getPreferredPairingOffer(environment), - ...(options.endpoint ? { endpoint: options.endpoint } : {}) - } - const response = await sendRemoteRuntimeRequest( - verifiedPairing, - 'status.get', - undefined, - 15_000, - undefined, - options.signal, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - options.signal?.throwIfAborted() - if (!response.ok) { - throw new Error(`Runtime identity verification failed: ${response.error.message}`) - } - const status = verifyRemotePairingRuntimeStatus(response.result) - if (!status.ok) { - throw new Error(status.message) - } - const runtimeId = status.runtimeStatus.runtimeId - if ( - response._meta.runtimeId !== runtimeId || - (environment.runtimeId !== null && runtimeId !== environment.runtimeId) || - (environment.pairedDeviceId !== undefined && - status.runtimeStatus.pairedDeviceId !== undefined && - status.runtimeStatus.pairedDeviceId !== environment.pairedDeviceId) - ) { - throw new Error('The endpoint does not match this paired runtime identity.') - } - return { verifiedPairing, verifiedRuntimeId: runtimeId, runtimeStatus: status.runtimeStatus } -} diff --git a/src/shared/runtime-environment-authority-binding.ts b/src/shared/runtime-environment-authority-binding.ts deleted file mode 100644 index 79b6e114957f..000000000000 --- a/src/shared/runtime-environment-authority-binding.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { KnownRuntimeEnvironment, PublicKnownRuntimeEnvironment } from './runtime-environments' - -/** Everything an SSH access operation was authorized against; any change invalidates the operation. */ -export function runtimeEnvironmentSshAccessBinding( - environment: KnownRuntimeEnvironment | PublicKnownRuntimeEnvironment, - ignoreIntent = false -): unknown { - return { - createdAt: environment.createdAt, - pairingRevision: environment.pairingRevision ?? environment.createdAt, - runtimeId: environment.runtimeId, - pairedDeviceId: environment.pairedDeviceId, - preferredEndpointId: environment.preferredEndpointId, - endpoints: environment.endpoints, - connectionDependency: environment.connectionDependency, - sshAccess: environment.sshAccess, - pendingSshAccessOperation: ignoreIntent ? undefined : environment.pendingSshAccessOperation - } -} diff --git a/src/shared/runtime-environment-reconciliation-record.ts b/src/shared/runtime-environment-reconciliation-record.ts deleted file mode 100644 index 232ef9bfb957..000000000000 --- a/src/shared/runtime-environment-reconciliation-record.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from 'zod' - -export const RuntimeEnvironmentReconciliationRecordSchema = z.object({ - version: z.literal(1), - stage: z.enum(['prepared', 'catalog-active']), - requestId: z.string().min(1), - canonicalEnvironmentId: z.string().min(1), - runtimeId: z.string().min(1), - preparedAt: z.number().finite(), - registrations: z - .array( - z.object({ - environmentId: z.string().min(1), - authorityDigest: z.string().regex(/^[a-f0-9]{64}$/) - }) - ) - .length(2) -}) - -export type RuntimeEnvironmentReconciliationRecord = z.infer< - typeof RuntimeEnvironmentReconciliationRecordSchema -> - -export function assertRuntimeEnvironmentNotReconciling(environment: { - reconciliation?: unknown -}): void { - if (environment.reconciliation) { - throw new Error('Finish or cancel host reconciliation before changing this server lifecycle.') - } -} diff --git a/src/shared/runtime-environment-sidecar.test.ts b/src/shared/runtime-environment-sidecar.test.ts deleted file mode 100644 index 0e372ddb53f0..000000000000 --- a/src/shared/runtime-environment-sidecar.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { z } from 'zod' -import { encodePairingOffer } from './pairing' -import { - addEnvironmentFromPairingCode, - getEnvironmentStorePath, - listEnvironments, - removeEnvironment -} from './runtime-environment-store' -import { - getRuntimeEnvironmentSidecarPath, - writeRuntimeEnvironmentSidecarEntry -} from './runtime-environment-sidecar' -import { assertRuntimeEnvironmentNotReconciling } from './runtime-environment-reconciliation-record' -import { - linkVerifiedRuntimeEnvironmentSshAccess, - prepareRuntimeEnvironmentSshAccessLink -} from './runtime-environment-ssh-access-store' -import { readPersistedEnvironmentStore } from './runtime-environment-store-file' - -const pairing = { - v: 2 as const, - endpoint: 'wss://server.example/runtime', - publicKeyB64: Buffer.alloc(32, 1).toString('base64'), - deviceToken: 'private-device-token', - pairedDeviceId: 'paired-client' -} -const tunnel = { sshTargetId: 'target', sshTargetGeneration: 3, localPort: 41000, remotePort: 6768 } - -// The environment schema v1.4.217 and v1.4.218 shipped: a plain z.object, so unknown keys are -// stripped, and every write (lastUsedAt included) rewrites the whole file. -const ShippedEnvironmentSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - createdAt: z.number().finite(), - updatedAt: z.number().finite(), - pairingRevision: z.number().finite().optional(), - pairedDeviceId: z.string().min(1).optional(), - lastUsedAt: z.number().finite().nullable(), - runtimeId: z.string().min(1).nullable(), - source: z.enum(['manual', 'ephemeral-vm']).optional(), - connectionDependency: z.literal('ssh-tunnel').optional(), - endpoints: z.array(z.object({}).passthrough()).min(1), - preferredEndpointId: z.string().min(1) -}) -const ShippedStoreSchema = z.object({ - version: z.literal(1), - environments: z.array(ShippedEnvironmentSchema) -}) - -describe('runtime environment sidecar across a downgrade', () => { - let userDataPath: string - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-environment-sidecar-')) - }) - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - }) - - function linked() { - const seeded = addEnvironmentFromPairingCode(userDataPath, { - name: 'Host', - pairingCode: encodePairingOffer(pairing), - now: 100 - }) - const prepared = prepareRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: seeded, - requestId: 'link-request', - ...tunnel, - targetFingerprint: 'target-fingerprint' - }) - return linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - expectedEnvironment: prepared, - requestId: 'link-request', - verifiedRuntimeId: 'host-runtime', - verifiedPairing: { ...pairing, endpoint: 'ws://127.0.0.1:41000/runtime' }, - tunnel, - now: 90 - }) - } - - function shippedBuildRewrite( - edit: (environments: z.infer[]) => void - ) { - const path = getEnvironmentStorePath(userDataPath) - const store = ShippedStoreSchema.parse(JSON.parse(readFileSync(path, 'utf8'))) - edit(store.environments) - writeFileSync(path, JSON.stringify(store)) - } - - it('keeps SSH access after a shipped build rewrites orca-environments.json', () => { - const access = linked() - shippedBuildRewrite((environments) => { - environments[0]!.lastUsedAt = 500 - environments[0]!.runtimeId = 'host-runtime' - }) - const [restored] = listEnvironments(userDataPath) - expect(restored?.sshAccess).toEqual(access.sshAccess) - expect(restored?.preferredEndpointId).toBe(access.preferredEndpointId) - expect(restored?.lastUsedAt).toBe(500) - }) - - it('reads a link as stale once a shipped build re-pairs the server, and prunes it on the next write', () => { - const access = linked() - shippedBuildRewrite((environments) => { - environments[0]!.pairingRevision = 200 - }) - const [restored] = listEnvironments(userDataPath) - expect(restored?.sshAccess).toBeUndefined() - expect(restored?.preferredEndpointId).toBe(access.sshAccess?.previousPreferredEndpointId) - expect(restored?.connectionDependency).toBeUndefined() - - const persisted = readPersistedEnvironmentStore(userDataPath).environments - writeRuntimeEnvironmentSidecarEntry(userDataPath, persisted, persisted[0]!, null) - expect( - JSON.parse(readFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), 'utf8')) - ).toEqual({ version: 1, entries: {} }) - }) - - it('ignores a dangling entry for a server a shipped build removed', () => { - linked() - shippedBuildRewrite((environments) => { - environments.splice(0, 1) - }) - expect(listEnvironments(userDataPath)).toEqual([]) - const other = addEnvironmentFromPairingCode(userDataPath, { - name: 'Other', - pairingCode: encodePairingOffer(pairing) - }) - expect(listEnvironments(userDataPath)).toEqual([other]) - }) - - it('refuses ordinary removal while linked, and drops the entry when an unlinked server is removed', () => { - const access = linked() - expect(() => removeEnvironment(userDataPath, access.id)).toThrow('Unlink') - const plain = addEnvironmentFromPairingCode(userDataPath, { - name: 'Plain', - pairingCode: encodePairingOffer(pairing) - }) - removeEnvironment(userDataPath, plain.id) - expect(listEnvironments(userDataPath).map((entry) => entry.id)).toEqual([access.id]) - }) - - it('fails closed on an unreadable sidecar instead of dropping a pinned host key', () => { - linked() - writeFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), '{not json') - expect(() => listEnvironments(userDataPath)).toThrow('Orca environment links') - }) - - it('overlays a reconciliation record so lifecycle changes refuse while it exists', () => { - const seeded = addEnvironmentFromPairingCode(userDataPath, { - name: 'Host', - pairingCode: encodePairingOffer(pairing), - now: 100 - }) - const persisted = readPersistedEnvironmentStore(userDataPath).environments - writeRuntimeEnvironmentSidecarEntry(userDataPath, persisted, persisted[0]!, { - reconciliation: { - version: 1, - stage: 'prepared', - requestId: 'reconcile', - canonicalEnvironmentId: seeded.id, - runtimeId: 'host-runtime', - preparedAt: 1, - registrations: [ - { environmentId: seeded.id, authorityDigest: 'a'.repeat(64) }, - { environmentId: 'other', authorityDigest: 'b'.repeat(64) } - ] - } - }) - const [restored] = listEnvironments(userDataPath) - expect(() => assertRuntimeEnvironmentNotReconciling(restored!)).toThrow('reconciliation') - expect(JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8'))).toEqual({ - version: 1, - environments: [seeded] - }) - }) -}) diff --git a/src/shared/runtime-environment-sidecar.ts b/src/shared/runtime-environment-sidecar.ts deleted file mode 100644 index 468517814535..000000000000 --- a/src/shared/runtime-environment-sidecar.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { z } from 'zod' -import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' -import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' -import { RuntimeEnvironmentReconciliationRecordSchema } from './runtime-environment-reconciliation-record' -import { - KnownRuntimeEnvironmentSchema, - RuntimeAccessEndpointSchema, - RuntimeSshAccessLinkSchema, - RuntimeSshAccessOperationSchema, - type KnownRuntimeEnvironment, - type PersistedRuntimeEnvironment -} from './runtime-environments' -import { hardenExistingSecureFile } from './secure-file' - -// Why a sidecar: shipped builds parse orca-environments.json with key-stripping schemas and rewrite -// it on routine use, so T5 state stored there would vanish across a downgrade. They never touch this. -const SIDECAR_FILE = 'orca-environment-sidecar.json' -const MAX_SIDECAR_FILE_BYTES = 1024 * 1024 - -const SidecarBindingSchema = z.object({ - createdAt: z.number().finite(), - pairingRevision: z.number().finite(), - preferredEndpointId: z.string().min(1) -}) - -const SidecarSshAccessSchema = RuntimeSshAccessLinkSchema.extend({ - endpoint: RuntimeAccessEndpointSchema -}) - -const SidecarEntrySchema = z.object({ - binding: SidecarBindingSchema, - // The runtime a link verified; it outlives the link like a learned runtimeId would. - runtimeId: z.string().min(1).optional(), - sshAccess: SidecarSshAccessSchema.optional(), - pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(), - // Keeps the overlaid pairing revision monotonic after the access that raised it is removed. - pairingRevisionFloor: z.number().finite().optional(), - reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional() -}) - -const SidecarSchema = z.object({ - version: z.literal(1), - entries: z.record(z.string().min(1), SidecarEntrySchema) -}) - -export type RuntimeEnvironmentSidecarEntry = z.infer -export type RuntimeEnvironmentSidecarSshAccess = z.infer -type RuntimeEnvironmentSidecar = z.infer - -export class RuntimeEnvironmentSidecarInvalidError extends Error { - constructor(path: string) { - super(`Could not read Orca environment links at ${path}; the file is invalid.`) - this.name = 'RuntimeEnvironmentSidecarInvalidError' - } -} - -export function getRuntimeEnvironmentSidecarPath(userDataPath: string): string { - return join(userDataPath, SIDECAR_FILE) -} - -/** The persisted state a sidecar entry was written against; any change makes the entry stale. */ -export function runtimeEnvironmentSidecarBinding(environment: PersistedRuntimeEnvironment) { - return { - createdAt: environment.createdAt, - pairingRevision: environment.pairingRevision ?? environment.createdAt, - preferredEndpointId: environment.preferredEndpointId - } -} - -export function readRuntimeEnvironmentSidecar(userDataPath: string): RuntimeEnvironmentSidecar { - const path = getRuntimeEnvironmentSidecarPath(userDataPath) - if (!existsSync(path)) { - return { version: 1, entries: {} } - } - try { - hardenExistingSecureFile(path) - return SidecarSchema.parse( - JSON.parse(readNodeFileSyncWithinLimit(path, MAX_SIDECAR_FILE_BYTES).buffer.toString('utf8')) - ) - } catch { - throw new RuntimeEnvironmentSidecarInvalidError(path) - } -} - -function isCurrentEntry( - environment: PersistedRuntimeEnvironment, - entry: RuntimeEnvironmentSidecarEntry -): boolean { - const binding = runtimeEnvironmentSidecarBinding(environment) - return ( - entry.binding.createdAt === binding.createdAt && - entry.binding.pairingRevision === binding.pairingRevision && - entry.binding.preferredEndpointId === binding.preferredEndpointId - ) -} - -/** - * The environment callers see. An entry bound to a different pairing, or one whose overlay would be - * inconsistent, is stale and ignored rather than trusted. - */ -export function overlayRuntimeEnvironmentSidecar( - environment: PersistedRuntimeEnvironment, - entry: RuntimeEnvironmentSidecarEntry | undefined -): KnownRuntimeEnvironment { - const base = KnownRuntimeEnvironmentSchema.parse(environment) - if (!entry || !isCurrentEntry(environment, entry)) { - return base - } - const basePairingRevision = environment.pairingRevision ?? environment.createdAt - const access = entry.sshAccess - if ( - entry.runtimeId !== undefined && - environment.runtimeId !== null && - environment.runtimeId !== entry.runtimeId - ) { - return base - } - const pairingRevision = Math.max( - basePairingRevision, - entry.pairingRevisionFloor ?? basePairingRevision - ) - const { endpoint, ...link } = access ?? {} - const overlaid = KnownRuntimeEnvironmentSchema.safeParse({ - ...environment, - ...(pairingRevision !== basePairingRevision ? { pairingRevision } : {}), - ...(entry.runtimeId !== undefined - ? { runtimeId: environment.runtimeId ?? entry.runtimeId } - : {}), - ...(access && endpoint - ? { - connectionDependency: 'ssh-tunnel', - sshAccess: link, - endpoints: [...environment.endpoints.filter((e) => e.id !== endpoint.id), endpoint], - preferredEndpointId: endpoint.id - } - : {}), - ...(entry.pendingSshAccessOperation - ? { pendingSshAccessOperation: entry.pendingSshAccessOperation } - : {}), - ...(entry.reconciliation ? { reconciliation: entry.reconciliation } : {}) - }) - return overlaid.success ? overlaid.data : base -} - -/** Replaces one environment's sidecar entry and drops entries whose environment no longer exists. */ -export function writeRuntimeEnvironmentSidecarEntry( - userDataPath: string, - environments: readonly PersistedRuntimeEnvironment[], - environment: PersistedRuntimeEnvironment, - entry: Omit | null -): void { - const current = readRuntimeEnvironmentSidecar(userDataPath) - const live = new Map(environments.map((candidate) => [candidate.id, candidate])) - const entries: Record = {} - for (const [id, existing] of Object.entries(current.entries)) { - const owner = live.get(id) - if (id !== environment.id && owner && isCurrentEntry(owner, existing)) { - entries[id] = existing - } - } - const basePairingRevision = environment.pairingRevision ?? environment.createdAt - const hasState = - entry !== null && - ((entry.runtimeId !== undefined && environment.runtimeId === null) || - entry.sshAccess !== undefined || - entry.pendingSshAccessOperation !== undefined || - entry.reconciliation !== undefined || - (entry.pairingRevisionFloor ?? basePairingRevision) > basePairingRevision) - if (entry && hasState) { - entries[environment.id] = SidecarEntrySchema.parse({ - ...entry, - binding: runtimeEnvironmentSidecarBinding(environment) - }) - } - writeSecureJsonFileWithinLimit( - getRuntimeEnvironmentSidecarPath(userDataPath), - SidecarSchema.parse({ version: 1, entries }), - MAX_SIDECAR_FILE_BYTES, - { durable: true } - ) -} - -/** The current sidecar entry for an environment, or none when absent or stale. */ -export function readCurrentRuntimeEnvironmentSidecarEntry( - userDataPath: string, - environment: PersistedRuntimeEnvironment -): RuntimeEnvironmentSidecarEntry | undefined { - const entry = readRuntimeEnvironmentSidecar(userDataPath).entries[environment.id] - return entry && isCurrentEntry(environment, entry) ? entry : undefined -} diff --git a/src/shared/runtime-environment-ssh-access-store.test.ts b/src/shared/runtime-environment-ssh-access-store.test.ts deleted file mode 100644 index b51adc71b8fb..000000000000 --- a/src/shared/runtime-environment-ssh-access-store.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { encodePairingOffer } from './pairing' -import { - addEnvironmentFromPairingCode, - getEnvironmentStorePath, - listEnvironments, - markEnvironmentUsed, - removeEnvironment, - updateEnvironmentFromPairingCode -} from './runtime-environment-store' -import { getPreferredPairingOffer, PersistedRuntimeEnvironmentSchema } from './runtime-environments' -import { - linkVerifiedRuntimeEnvironmentSshAccess, - prepareRuntimeEnvironmentSshAccessLink, - prepareRuntimeEnvironmentSshAccessUnlink, - completeRuntimeEnvironmentSshAccessUnlink, - cancelRuntimeEnvironmentSshAccessLink -} from './runtime-environment-ssh-access-store' -import { z } from 'zod' - -const pairing = { - v: 2 as const, - endpoint: 'wss://server.example/runtime', - publicKeyB64: Buffer.alloc(32, 1).toString('base64'), - deviceToken: 'private-device-token', - pairedDeviceId: 'paired-client' -} -const tunnel = { sshTargetId: 'target', sshTargetGeneration: 3, localPort: 41000, remotePort: 6768 } -const verifiedPairing = { ...pairing, endpoint: 'ws://127.0.0.1:41000/runtime' } - -describe('independent paired runtime SSH access persistence', () => { - let userDataPath: string - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-ssh-access-store-')) - }) - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - }) - - function seed() { - return addEnvironmentFromPairingCode(userDataPath, { - name: 'Independent host', - pairingCode: encodePairingOffer(pairing), - now: 100 - }) - } - - function prepare(expectedEnvironment = seed(), requestId = 'link-request') { - return prepareRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment, - requestId, - ...tunnel, - targetFingerprint: 'target-fingerprint' - }) - } - - function link(expectedEnvironment = seed()) { - return linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - expectedEnvironment: prepare(expectedEnvironment), - requestId: 'link-request', - verifiedRuntimeId: 'host-runtime', - verifiedPairing, - tunnel, - now: 90 - }) - } - - function unlink(expectedEnvironment: ReturnType, now?: number) { - const prepared = prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment, - requestId: 'unlink-request', - now - }) - return completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'unlink-request' - }) - } - - it('adds access to the same host without deployment ownership and restores its original endpoint', () => { - const original = seed() - const linked = link(original) - expect(listEnvironments(userDataPath)).toEqual([linked]) - expect(linked.id).toBe(original.id) - expect(linked.name).toBe(original.name) - expect(linked.createdAt).toBe(original.createdAt) - expect(linked.runtimeId).toBe('host-runtime') - expect(linked.pairingRevision).toBe(101) - expect(linked.endpoints).toHaveLength(2) - expect(linked.endpoints[0]).toEqual(original.endpoints[0]) - expect(getPreferredPairingOffer(linked)).toEqual(verifiedPairing) - - const unlinked = unlink(linked, 80) - expect(unlinked.id).toBe(original.id) - expect(unlinked.runtimeId).toBe('host-runtime') - expect(unlinked.pairingRevision).toBe(102) - expect(unlinked.endpoints).toEqual(original.endpoints) - expect(unlinked.preferredEndpointId).toBe(original.preferredEndpointId) - expect(unlinked.sshAccess).toBeUndefined() - expect(unlinked.connectionDependency).toBeUndefined() - expect(listEnvironments(userDataPath)).toEqual([unlinked]) - }) - - it('refuses a link whose verification raced with re-pairing', () => { - const original = seed() - const replaced = updateEnvironmentFromPairingCode(userDataPath, original.id, { - pairingCode: encodePairingOffer({ ...pairing, deviceToken: 'replacement-token' }), - now: 100 - }) - expect(() => link(original)).toThrow('changed while SSH access') - expect(listEnvironments(userDataPath)).toEqual([replaced]) - }) - - it('permits unrelated last-used timestamp updates during verification', () => { - const original = seed() - markEnvironmentUsed(userDataPath, original.id, { now: 200 }) - expect(link(original).lastUsedAt).toBe(200) - }) - - it('keeps orca-environments.json in the shape shipped builds read while SSH access is linked', () => { - const original = seed() - const shippedEnvelope = z - .object({ - version: z.literal(1), - environments: z.array(PersistedRuntimeEnvironmentSchema.strict()) - }) - .strict() - const readEnvelope = () => - JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8')) - expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true) - const linked = link(original) - expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true) - expect(readEnvelope().environments[0]).toEqual(original) - markEnvironmentUsed(userDataPath, linked.id, { now: 500 }) - expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true) - expect(listEnvironments(userDataPath)[0]?.sshAccess).toEqual(linked.sshAccess) - unlink(linked) - expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true) - }) - - it.each([ - { ...verifiedPairing, publicKeyB64: Buffer.alloc(32, 2).toString('base64') }, - { ...verifiedPairing, deviceToken: 'another-token' }, - { ...verifiedPairing, pairedDeviceId: 'another-client' } - ])('refuses a different authenticated host or pairing grant', (wrongPairing) => { - const original = seed() - const prepared = prepare(original) - expect(() => - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - expectedEnvironment: prepared, - requestId: 'link-request', - verifiedRuntimeId: 'host-runtime', - verifiedPairing: wrongPairing, - tunnel - }) - ).toThrow('did not verify') - expect(listEnvironments(userDataPath)).toEqual([prepared]) - }) - - it('refuses changing a known execution runtime identity', () => { - const original = seed() - markEnvironmentUsed(userDataPath, original.id, { runtimeId: 'incumbent-runtime' }) - const current = listEnvironments(userDataPath)[0] - expect(() => link(current)).toThrow('did not verify') - expect(listEnvironments(userDataPath)[0]).toMatchObject({ - ...current, - pendingSshAccessOperation: { operation: 'link' } - }) - }) - - it('refuses a second registration of the same execution runtime', () => { - const original = seed() - const duplicate = addEnvironmentFromPairingCode(userDataPath, { - name: 'Duplicate host', - pairingCode: encodePairingOffer(pairing) - }) - markEnvironmentUsed(userDataPath, duplicate.id, { runtimeId: 'host-runtime' }) - const before = listEnvironments(userDataPath) - expect(() => link(original)).toThrow('registered more than once') - expect( - listEnvironments(userDataPath).find((entry) => entry.id === original.id) - ?.pendingSshAccessOperation?.operation - ).toBe('link') - expect(listEnvironments(userDataPath).find((entry) => entry.id === duplicate.id)).toEqual( - before.find((entry) => entry.id === duplicate.id) - ) - }) - - it('prevents ordinary removal or re-pairing from orphaning SSH access', () => { - const linked = link() - expect(() => removeEnvironment(userDataPath, linked.id)).toThrow('Unlink') - expect(() => - updateEnvironmentFromPairingCode(userDataPath, linked.id, { - pairingCode: encodePairingOffer(pairing) - }) - ).toThrow('Unlink') - expect(() => link(linked)).toThrow('already has SSH access') - expect(listEnvironments(userDataPath)).toEqual([linked]) - }) - - it('does not let a stale unlink remove a subsequently replaced SSH link', () => { - const first = link() - const unlinked = unlink(first) - const replacement = link(unlinked) - expect(() => unlink(first)).toThrow('changed while SSH access') - expect(listEnvironments(userDataPath)).toEqual([replacement]) - }) - - it('persists retryable link intent before target claim and fences pairing mutations', () => { - const original = seed() - const prepared = prepare(original) - expect(prepared.endpoints).toEqual(original.endpoints) - expect(prepared.sshAccess).toBeUndefined() - expect(listEnvironments(userDataPath)).toEqual([prepared]) - expect(JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8')).version).toBe(1) - expect(prepare(original)).toEqual(prepared) - expect(prepare(prepared)).toEqual(prepared) - expect(() => prepare(prepared, 'other-request')).toThrow('Another SSH access') - expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Unlink') - expect(() => - updateEnvironmentFromPairingCode(userDataPath, prepared.id, { - pairingCode: encodePairingOffer(pairing) - }) - ).toThrow('Unlink') - expect(() => - markEnvironmentUsed(userDataPath, prepared.id, { runtimeId: 'different-runtime' }) - ).toThrow('cannot change') - }) - - it('completes a matching verified link atomically and handles lost completion responses', () => { - const prepared = prepare() - const args = { - expectedEnvironment: prepared, - requestId: 'link-request', - verifiedRuntimeId: 'host-runtime', - verifiedPairing, - tunnel - } - expect(() => - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { ...args, requestId: 'stale-request' }) - ).toThrow('pending link intent') - expect(() => - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - ...args, - tunnel: { ...tunnel, sshTargetGeneration: 4 } - }) - ).toThrow('pending link intent') - const linked = linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, args) - expect(linked.pendingSshAccessOperation).toBeUndefined() - expect(linked.sshAccess?.requestId).toBe('link-request') - expect(linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, args)).toEqual(linked) - expect(() => - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - ...args, - verifiedRuntimeId: 'impostor' - }) - ).toThrow('completed link') - }) - - it('keeps a durable release intent after restoring the endpoint until exact completion', () => { - const linked = link() - const prepared = prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: linked, - requestId: 'release-request' - }) - expect(prepared.sshAccess).toBeUndefined() - expect(getPreferredPairingOffer(prepared)).toEqual(pairing) - expect(prepared.pendingSshAccessOperation).toMatchObject({ - operation: 'unlink', - requestId: 'release-request', - sshTargetId: tunnel.sshTargetId, - sshTargetGeneration: tunnel.sshTargetGeneration - }) - expect(JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8')).version).toBe(1) - expect( - prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'release-request' - }) - ).toEqual(prepared) - expect(() => - completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'stale-request' - }) - ).toThrow('pending unlink intent') - expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Unlink') - const finished = completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'release-request' - }) - expect(finished.pendingSshAccessOperation).toBeUndefined() - }) - - it('turns failed verification into durable release intent without ever publishing access', () => { - const prepared = prepare() - expect(() => - cancelRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'wrong' - }) - ).toThrow('pending link intent') - const cancelling = cancelRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'link-request' - }) - expect(cancelling.endpoints).toEqual(prepared.endpoints) - expect(cancelling.pendingSshAccessOperation?.operation).toBe('unlink') - expect(cancelling.sshAccess).toBeUndefined() - expect( - cancelRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: cancelling, - requestId: 'link-request' - }) - ).toEqual(cancelling) - const finished = completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: cancelling, - requestId: 'link-request' - }) - expect(finished.pendingSshAccessOperation).toBeUndefined() - }) -}) diff --git a/src/shared/runtime-environment-ssh-access-store.ts b/src/shared/runtime-environment-ssh-access-store.ts deleted file mode 100644 index db14401223c7..000000000000 --- a/src/shared/runtime-environment-ssh-access-store.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { runtimeEnvironmentSshAccessBinding } from './runtime-environment-authority-binding' -export { runtimeEnvironmentSshAccessBinding } from './runtime-environment-authority-binding' -import type { PairingOffer } from './pairing' -import { - getPreferredPairingOffer, - RuntimeSshAccessOperationSchema, - type KnownRuntimeEnvironment, - type PersistedRuntimeEnvironment, - type RuntimeSshAccessOperation, - type RuntimeSshTunnelLink -} from './runtime-environments' -import { - readEnvironmentStore, - readPersistedEnvironmentStore, - RuntimeEnvironmentStoreError -} from './runtime-environment-store-file' -import { - overlayRuntimeEnvironmentSidecar, - readCurrentRuntimeEnvironmentSidecarEntry, - runtimeEnvironmentSidecarBinding, - writeRuntimeEnvironmentSidecarEntry, - type RuntimeEnvironmentSidecarEntry -} from './runtime-environment-sidecar' - -type SidecarState = Omit - -type AccessContext = { - environments: PersistedRuntimeEnvironment[] - persisted: PersistedRuntimeEnvironment - entry: SidecarState - view: KnownRuntimeEnvironment -} - -export function prepareRuntimeEnvironmentSshAccessLink( - userDataPath: string, - args: { - expectedEnvironment: KnownRuntimeEnvironment - requestId: string - sshTargetId: string - sshTargetGeneration: number - remotePort: number - targetFingerprint: string - } -): KnownRuntimeEnvironment { - const { expectedEnvironment: _expected, ...fields } = args - const intent = RuntimeSshAccessOperationSchema.parse({ ...fields, operation: 'link' }) - const context = readAccessContext(userDataPath, args.expectedEnvironment, true) - const existing = context.view - if (existing.pendingSshAccessOperation) { - requireMatchingIntent(existing, intent) - return existing - } - if (existing.sshAccess) { - throw invalid('This server already has SSH access.') - } - if (existing.connectionDependency) { - throw invalid('Unlink the existing external tunnel before adding SSH access.') - } - return commit(userDataPath, context, { ...context.entry, pendingSshAccessOperation: intent }) -} - -export function linkVerifiedRuntimeEnvironmentSshAccess( - userDataPath: string, - args: { - expectedEnvironment: KnownRuntimeEnvironment - requestId: string - verifiedRuntimeId: string - verifiedPairing: PairingOffer - tunnel: RuntimeSshTunnelLink - now?: number - } -): KnownRuntimeEnvironment { - const views = readEnvironmentStore(userDataPath).environments - const completed = views.find((entry) => entry.id === args.expectedEnvironment.id) - if (completed?.sshAccess?.requestId && completed.sshAccess.requestId === args.requestId) { - return requireMatchingCompletedLink(completed, args) - } - const context = readAccessContext(userDataPath, args.expectedEnvironment) - const existing = context.view - const intent = existing.pendingSshAccessOperation - if ( - !intent || - intent.operation !== 'link' || - intent.requestId !== args.requestId || - intent.sshTargetId !== args.tunnel.sshTargetId || - intent.sshTargetGeneration !== args.tunnel.sshTargetGeneration || - intent.remotePort !== args.tunnel.remotePort - ) { - throw invalid('SSH access completion does not match its pending link intent.') - } - const offer = getPreferredPairingOffer(existing) - if ( - !args.verifiedRuntimeId.trim() || - (existing.runtimeId !== null && existing.runtimeId !== args.verifiedRuntimeId) || - args.verifiedPairing.publicKeyB64 !== offer.publicKeyB64 || - args.verifiedPairing.deviceToken !== offer.deviceToken || - args.verifiedPairing.pairedDeviceId !== offer.pairedDeviceId - ) { - throw invalid('The SSH endpoint did not verify as this paired server.') - } - if ( - views.some((entry) => entry.id !== existing.id && entry.runtimeId === args.verifiedRuntimeId) - ) { - throw invalid( - 'This runtime is registered more than once. Reconcile its existing registrations first.' - ) - } - const endpointId = `ssh-${randomUUID()}` - const { pendingSshAccessOperation: _intent, ...remaining } = context.entry - return commit(userDataPath, context, { - ...remaining, - pairingRevisionFloor: nextPairingRevision(existing, args.now ?? Date.now()), - runtimeId: args.verifiedRuntimeId, - sshAccess: { - ...args.tunnel, - requestId: intent.requestId, - targetFingerprint: intent.targetFingerprint, - endpointId, - previousPreferredEndpointId: existing.preferredEndpointId, - endpoint: { - id: endpointId, - kind: 'websocket', - label: 'SSH tunnel', - endpoint: args.verifiedPairing.endpoint, - publicKeyB64: offer.publicKeyB64, - deviceToken: offer.deviceToken - } - } - }) -} - -export function prepareRuntimeEnvironmentSshAccessUnlink( - userDataPath: string, - args: { expectedEnvironment: KnownRuntimeEnvironment; requestId: string; now?: number } -): KnownRuntimeEnvironment { - const context = readAccessContext(userDataPath, args.expectedEnvironment) - const existing = context.view - if (existing.pendingSshAccessOperation) { - if ( - existing.pendingSshAccessOperation.operation === 'unlink' && - existing.pendingSshAccessOperation.requestId === args.requestId - ) { - return existing - } - throw invalid('Another SSH access operation is pending.') - } - const sshAccess = existing.sshAccess - if (!sshAccess) { - throw invalid('This server does not have independently linked SSH access.') - } - const { sshAccess: _access, ...remaining } = context.entry - return commit(userDataPath, context, { - ...remaining, - pairingRevisionFloor: nextPairingRevision(existing, args.now ?? Date.now()), - pendingSshAccessOperation: { - requestId: args.requestId, - operation: 'unlink', - sshTargetId: sshAccess.sshTargetId, - sshTargetGeneration: sshAccess.sshTargetGeneration, - remotePort: sshAccess.remotePort, - targetFingerprint: sshAccess.targetFingerprint - } - }) -} - -export function completeRuntimeEnvironmentSshAccessUnlink( - userDataPath: string, - args: { expectedEnvironment: KnownRuntimeEnvironment; requestId: string } -): KnownRuntimeEnvironment { - const context = readAccessContext(userDataPath, args.expectedEnvironment) - const intent = context.view.pendingSshAccessOperation - if (!intent || intent.operation !== 'unlink' || intent.requestId !== args.requestId) { - throw invalid('SSH access completion does not match its pending unlink intent.') - } - const { pendingSshAccessOperation: _intent, ...remaining } = context.entry - return commit(userDataPath, context, remaining) -} - -export function cancelRuntimeEnvironmentSshAccessLink( - userDataPath: string, - args: { expectedEnvironment: KnownRuntimeEnvironment; requestId: string } -): KnownRuntimeEnvironment { - const context = readAccessContext(userDataPath, args.expectedEnvironment) - const intent = context.view.pendingSshAccessOperation - if (!intent || intent.requestId !== args.requestId) { - throw invalid('SSH access cancellation does not match its pending link intent.') - } - if (intent.operation === 'unlink') { - return context.view - } - return commit(userDataPath, context, { - ...context.entry, - pendingSshAccessOperation: { ...intent, operation: 'unlink' } - }) -} - -/** A retried completion must name exactly the link it already completed. */ -function requireMatchingCompletedLink( - completed: KnownRuntimeEnvironment, - args: Parameters[1] -): KnownRuntimeEnvironment { - const access = completed.sshAccess! - const expectedIntent = args.expectedEnvironment.pendingSshAccessOperation - const prior = expectedIntent - ? { - ...completed, - runtimeId: args.expectedEnvironment.runtimeId, - pairingRevision: args.expectedEnvironment.pairingRevision, - sshAccess: undefined, - connectionDependency: undefined, - pendingSshAccessOperation: expectedIntent, - preferredEndpointId: access.previousPreferredEndpointId, - endpoints: completed.endpoints.filter((entry) => entry.id !== access.endpointId) - } - : completed - requireUnchangedEnvironment([prior], args.expectedEnvironment) - const offer = getPreferredPairingOffer(completed) - if ( - completed.runtimeId !== args.verifiedRuntimeId || - offer.endpoint !== args.verifiedPairing.endpoint || - offer.deviceToken !== args.verifiedPairing.deviceToken || - offer.publicKeyB64 !== args.verifiedPairing.publicKeyB64 || - offer.pairedDeviceId !== args.verifiedPairing.pairedDeviceId || - access.sshTargetId !== args.tunnel.sshTargetId || - access.sshTargetGeneration !== args.tunnel.sshTargetGeneration || - access.localPort !== args.tunnel.localPort || - access.remotePort !== args.tunnel.remotePort || - (expectedIntent && - (expectedIntent.operation !== 'link' || - expectedIntent.requestId !== args.requestId || - expectedIntent.targetFingerprint !== access.targetFingerprint)) - ) { - throw invalid('SSH access retry does not match its completed link.') - } - return completed -} - -function readAccessContext( - userDataPath: string, - expected: KnownRuntimeEnvironment, - ignoreIntent = false -): AccessContext { - const { environments } = readPersistedEnvironmentStore(userDataPath) - const views = readEnvironmentStore(userDataPath).environments - const view = requireUnchangedEnvironment(views, expected, ignoreIntent) - const persisted = environments.find((entry) => entry.id === view.id)! - const current = readCurrentRuntimeEnvironmentSidecarEntry(userDataPath, persisted) - const { binding: _binding, ...entry } = current ?? { binding: undefined } - return { environments, persisted, entry, view } -} - -/** Writes only after the overlay proves every requested field survives validation. */ -function commit( - userDataPath: string, - context: AccessContext, - next: SidecarState -): KnownRuntimeEnvironment { - const view = overlayRuntimeEnvironmentSidecar(context.persisted, { - ...next, - binding: runtimeEnvironmentSidecarBinding(context.persisted) - }) - if ( - (next.sshAccess !== undefined) !== (view.sshAccess !== undefined) || - (next.pendingSshAccessOperation !== undefined) !== - (view.pendingSshAccessOperation !== undefined) - ) { - throw invalid('The SSH access state is inconsistent with this paired server.') - } - writeRuntimeEnvironmentSidecarEntry(userDataPath, context.environments, context.persisted, next) - return view -} - -function requireMatchingIntent( - environment: KnownRuntimeEnvironment, - intent: RuntimeSshAccessOperation -): void { - if (JSON.stringify(environment.pendingSshAccessOperation) !== JSON.stringify(intent)) { - throw invalid('Another SSH access operation is pending.') - } -} - -function requireUnchangedEnvironment( - environments: KnownRuntimeEnvironment[], - expected: KnownRuntimeEnvironment, - ignoreIntent = false -): KnownRuntimeEnvironment { - const existing = environments.find((entry) => entry.id === expected.id) - if ( - !existing || - JSON.stringify(runtimeEnvironmentSshAccessBinding(existing, ignoreIntent)) !== - JSON.stringify(runtimeEnvironmentSshAccessBinding(expected, ignoreIntent)) - ) { - throw invalid( - 'The paired server changed while SSH access was being verified. Retry with its current pairing.' - ) - } - return existing -} - -function nextPairingRevision(environment: KnownRuntimeEnvironment, now: number): number { - return Math.max(now, (environment.pairingRevision ?? environment.createdAt) + 1) -} - -function invalid(message: string): RuntimeEnvironmentStoreError { - return new RuntimeEnvironmentStoreError('invalid_argument', message) -} diff --git a/src/shared/runtime-environment-store-file.ts b/src/shared/runtime-environment-store-file.ts deleted file mode 100644 index a69a3fe22eaf..000000000000 --- a/src/shared/runtime-environment-store-file.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' -import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' -import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' -import { - PersistedRuntimeEnvironmentSchema, - RuntimeEnvironmentStoreSchema, - type KnownRuntimeEnvironment, - type RuntimeEnvironmentStore -} from './runtime-environments' -import { - overlayRuntimeEnvironmentSidecar, - readRuntimeEnvironmentSidecar, - RuntimeEnvironmentSidecarInvalidError -} from './runtime-environment-sidecar' -import { hardenExistingSecureFile } from './secure-file' - -const ENVIRONMENTS_FILE = 'orca-environments.json' -export const MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES = 1024 * 1024 - -export type RuntimeEnvironmentStoreErrorCode = 'invalid_argument' | 'runtime_error' - -export class RuntimeEnvironmentStoreError extends Error { - readonly code: RuntimeEnvironmentStoreErrorCode - - constructor(code: RuntimeEnvironmentStoreErrorCode, message: string) { - super(message) - this.name = 'RuntimeEnvironmentStoreError' - this.code = code - } -} - -export function getEnvironmentStorePath(userDataPath: string): string { - return join(userDataPath, ENVIRONMENTS_FILE) -} - -/** The orca-environments.json content, exactly as shipped builds read and rewrite it. */ -export function readPersistedEnvironmentStore(userDataPath: string): RuntimeEnvironmentStore { - const path = getEnvironmentStorePath(userDataPath) - if (!existsSync(path)) { - return { version: 1, environments: [] } - } - try { - hardenExistingSecureFile(path) - const parsed = RuntimeEnvironmentStoreSchema.parse( - JSON.parse( - readNodeFileSyncWithinLimit(path, MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES).buffer.toString( - 'utf8' - ) - ) - ) - return { - version: 1, - environments: parsed.environments - .map((entry) => PersistedRuntimeEnvironmentSchema.parse(entry)) - .sort((a, b) => a.name.localeCompare(b.name)) - } - } catch { - throw new RuntimeEnvironmentStoreError( - 'runtime_error', - `Could not read Orca environments at ${path}; the file is invalid.` - ) - } -} - -/** Persisted environments with their sidecar state overlaid; stale sidecar entries are ignored. */ -export function readEnvironmentStore(userDataPath: string): { - version: 1 - environments: KnownRuntimeEnvironment[] -} { - const store = readPersistedEnvironmentStore(userDataPath) - let sidecar: ReturnType - try { - sidecar = readRuntimeEnvironmentSidecar(userDataPath) - } catch (error) { - // Fail closed like the main file: a link we cannot read may pin a host key we must not drop. - if (error instanceof RuntimeEnvironmentSidecarInvalidError) { - throw new RuntimeEnvironmentStoreError('runtime_error', error.message) - } - throw error - } - return { - version: 1, - environments: store.environments.map((environment) => - overlayRuntimeEnvironmentSidecar(environment, sidecar.entries[environment.id]) - ) - } -} - -/** Writes only persisted fields; sidecar state is written by the sidecar module. */ -export function writeEnvironmentStore(userDataPath: string, store: RuntimeEnvironmentStore): void { - const path = getEnvironmentStorePath(userDataPath) - try { - writeSecureJsonFileWithinLimit( - path, - RuntimeEnvironmentStoreSchema.parse(store), - MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES - ) - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - throw new RuntimeEnvironmentStoreError( - 'runtime_error', - `Could not write Orca environments at ${path}; the store exceeds its durable capacity.` - ) - } - throw error - } -} diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index bf08a7276a5b..e784dc802813 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -1,27 +1,40 @@ import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' +import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' import { parsePairingCode, type PairingOffer } from './pairing' import { classifyRemotePairingHostname } from './remote-pairing-address' +import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' +import { hardenExistingSecureFile } from './secure-file' import { createEnvironmentFromPairingOffer, getPreferredPairingOffer, + KnownRuntimeEnvironmentSchema, + RuntimeEnvironmentStoreSchema, type KnownRuntimeEnvironment, - type PersistedRuntimeEnvironment, - type RuntimeEnvironmentSource + type RuntimeEnvironmentSource, + type RuntimeEnvironmentStore } from './runtime-environments' -import { - readEnvironmentStore, - readPersistedEnvironmentStore, - RuntimeEnvironmentStoreError, - writeEnvironmentStore -} from './runtime-environment-store-file' -import { writeRuntimeEnvironmentSidecarEntry } from './runtime-environment-sidecar' - -export { - getEnvironmentStorePath, - MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES, - RuntimeEnvironmentStoreError, - type RuntimeEnvironmentStoreErrorCode -} from './runtime-environment-store-file' + +const ENVIRONMENTS_FILE = 'orca-environments.json' +export const MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES = 1024 * 1024 + +export type RuntimeEnvironmentStoreErrorCode = 'invalid_argument' | 'runtime_error' + +export class RuntimeEnvironmentStoreError extends Error { + readonly code: RuntimeEnvironmentStoreErrorCode + + constructor(code: RuntimeEnvironmentStoreErrorCode, message: string) { + super(message) + this.name = 'RuntimeEnvironmentStoreError' + this.code = code + } +} + +export function getEnvironmentStorePath(userDataPath: string): string { + return join(userDataPath, ENVIRONMENTS_FILE) +} export function listEnvironments(userDataPath: string): KnownRuntimeEnvironment[] { return readEnvironmentStore(userDataPath).environments @@ -44,7 +57,7 @@ export function addEnvironmentFromPairingCode( 'Invalid pairing code. Expected an orca://pair?... URL or bare pairing payload.' ) } - const store = readPersistedEnvironmentStore(userDataPath) + const store = readEnvironmentStore(userDataPath) const now = args.now ?? Date.now() const existing = store.environments.find((entry) => entry.name === args.name) if (existing) { @@ -74,14 +87,12 @@ export function addEnvironmentFromPairingCode( } export function removeEnvironment(userDataPath: string, selector: string): KnownRuntimeEnvironment { - const environment = resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), selector) - assertNoIndependentSshAccess(environment) - const store = readPersistedEnvironmentStore(userDataPath) - const persisted = resolveEnvironmentFromStore(store, environment.id) - const remaining = store.environments.filter((entry) => entry.id !== environment.id) - writeEnvironmentStore(userDataPath, { version: 1, environments: remaining }) - // A leftover entry would read as stale anyway; dropping it keeps the sidecar from growing. - writeRuntimeEnvironmentSidecarEntry(userDataPath, remaining, persisted, null) + const store = readEnvironmentStore(userDataPath) + const environment = resolveEnvironmentFromStore(store, selector) + writeEnvironmentStore(userDataPath, { + version: 1, + environments: store.environments.filter((entry) => entry.id !== environment.id) + }) return environment } @@ -97,10 +108,7 @@ export function updateEnvironmentFromPairingCode( 'Invalid pairing code. Expected an orca://pair?... URL or bare pairing payload.' ) } - assertNoIndependentSshAccess( - resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), selector) - ) - const store = readPersistedEnvironmentStore(userDataPath) + const store = readEnvironmentStore(userDataPath) const existing = resolveEnvironmentFromStore(store, selector) const now = args.now ?? Date.now() const previousPairingRevision = existing.pairingRevision ?? existing.createdAt @@ -170,7 +178,7 @@ export function markEnvironmentUsed( selector: string, args: { runtimeId?: string | null; pairedDeviceId?: string; now?: number } = {} ): void { - const store = readPersistedEnvironmentStore(userDataPath) + const store = readEnvironmentStore(userDataPath) const environment = resolveEnvironmentFromStore(store, selector) const now = args.now ?? Date.now() const runtimeIdChanged = args.runtimeId != null && args.runtimeId !== environment.runtimeId @@ -183,15 +191,6 @@ export function markEnvironmentUsed( if (!runtimeIdChanged && !pairedDeviceIdChanged && lastUsedIsFresh) { return } - if (runtimeIdChanged || pairedDeviceIdChanged) { - const current = resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environment.id) - if (current.sshAccess || current.pendingSshAccessOperation) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - 'SSH access operation cannot change the paired runtime identity.' - ) - } - } const next = store.environments.map((entry) => entry.id === environment.id ? { @@ -206,10 +205,10 @@ export function markEnvironmentUsed( writeEnvironmentStore(userDataPath, { version: 1, environments: next }) } -export function resolveEnvironmentFromStore( - store: { environments: T[] }, +function resolveEnvironmentFromStore( + store: RuntimeEnvironmentStore, selector: string -): T { +): KnownRuntimeEnvironment { const byId = store.environments.find((entry) => entry.id === selector) if (byId) { return byId @@ -227,11 +226,49 @@ export function resolveEnvironmentFromStore KnownRuntimeEnvironmentSchema.parse(entry)) + .sort((a, b) => a.name.localeCompare(b.name)) + } + } catch { throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - "Unlink this server's SSH access before replacing its pairing or removing it." + 'runtime_error', + `Could not read Orca environments at ${path}; the file is invalid.` ) } } + +function writeEnvironmentStore(userDataPath: string, store: RuntimeEnvironmentStore): void { + const path = getEnvironmentStorePath(userDataPath) + try { + writeSecureJsonFileWithinLimit( + path, + RuntimeEnvironmentStoreSchema.parse(store), + MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES + ) + } catch (error) { + if (error instanceof JsonStringifyByteLimitError) { + throw new RuntimeEnvironmentStoreError( + 'runtime_error', + `Could not write Orca environments at ${path}; the store exceeds its durable capacity.` + ) + } + throw error + } +} diff --git a/src/shared/runtime-environments-ssh-access.test.ts b/src/shared/runtime-environments-ssh-access.test.ts deleted file mode 100644 index d2ec327c1515..000000000000 --- a/src/shared/runtime-environments-ssh-access.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { PAIRING_OFFER_VERSION, type PairingOffer } from './pairing' -import { - createEnvironmentFromPairingOffer, - getPreferredPairingOffer, - getRuntimeSshAccess, - KnownRuntimeEnvironmentSchema, - redactRuntimeEnvironment, - RuntimeEnvironmentStoreSchema, - type RuntimeSshTunnelLink -} from './runtime-environments' - -const link: RuntimeSshTunnelLink = { - sshTargetId: 'ssh-host', - sshTargetGeneration: 7, - localPort: 46768, - remotePort: 6768 -} -const accessLink = { - ...link, - endpointId: 'ssh-access', - previousPreferredEndpointId: 'ws-environment-1' -} -const offer: PairingOffer = { - v: PAIRING_OFFER_VERSION, - endpoint: 'ws://127.0.0.1:46768', - deviceToken: 'secret-device-token', - publicKeyB64: 'secret-key', - pairedDeviceId: 'paired-device' -} -function accessEnvironment() { - const original = environment() - return { - ...original, - connectionDependency: 'ssh-tunnel' as const, - sshAccess: accessLink, - endpoints: [...original.endpoints, { ...original.endpoints[0]!, id: accessLink.endpointId }], - preferredEndpointId: accessLink.endpointId - } -} -function environment() { - return createEnvironmentFromPairingOffer({ - id: 'environment-1', - name: 'Host', - now: 1, - offer, - runtimeId: 'host-runtime' - }) -} - -describe('runtime SSH access without deployment ownership', () => { - it('keeps old stored environments valid without adding an SSH dependency', () => { - const original = environment() - const restored = RuntimeEnvironmentStoreSchema.parse({ version: 1, environments: [original] }) - .environments[0]! - expect(restored).toEqual(original) - expect(getRuntimeSshAccess(KnownRuntimeEnvironmentSchema.parse(restored))).toBeUndefined() - expect(restored).not.toHaveProperty('sshAccess') - // The persisted envelope never carries sidecar state, even when handed a linked environment. - const persisted = RuntimeEnvironmentStoreSchema.parse({ - version: 1, - environments: [accessEnvironment()] - }).environments[0]! - expect(persisted).not.toHaveProperty('sshAccess') - }) - - it.each([ - { sshTargetId: '' }, - { sshTargetGeneration: 0 }, - { sshTargetGeneration: 1.5 }, - { localPort: 0 }, - { localPort: 65536 }, - { remotePort: 0 }, - { remotePort: 65536 } - ])('rejects malformed generic access links: %j', (change) => { - expect( - KnownRuntimeEnvironmentSchema.safeParse({ - ...accessEnvironment(), - sshAccess: { ...accessLink, ...change } - }).success - ).toBe(false) - }) - - it('preserves SSH metadata while redacting the same pairing secrets', () => { - const accessed = KnownRuntimeEnvironmentSchema.parse(accessEnvironment()) - const redacted = redactRuntimeEnvironment(accessed) - expect(getRuntimeSshAccess(redacted)).toEqual(accessLink) - expect(redacted.endpoints[0]).not.toHaveProperty('deviceToken') - expect(redacted.endpoints[0]).not.toHaveProperty('publicKeyB64') - expect(JSON.stringify(redacted)).not.toContain('secret-') - expect(getPreferredPairingOffer(accessed)).toEqual(offer) - }) - - it.each([ - { endpointId: 'missing' }, - { previousPreferredEndpointId: 'missing' }, - { previousPreferredEndpointId: 'ssh-access' }, - { localPort: 46769 } - ])('rejects access links that cannot restore the prior endpoint: %j', (change) => { - expect( - KnownRuntimeEnvironmentSchema.safeParse({ - ...accessEnvironment(), - sshAccess: { ...accessLink, ...change } - }).success - ).toBe(false) - }) - - it('rejects an unpreferred SSH access endpoint', () => { - expect( - KnownRuntimeEnvironmentSchema.safeParse({ - ...accessEnvironment(), - preferredEndpointId: accessLink.previousPreferredEndpointId - }).success - ).toBe(false) - }) - - it('rejects a non-loopback SSH access endpoint', () => { - const accessed = accessEnvironment() - accessed.endpoints[1]!.endpoint = 'ws://remote.example:46768' - expect(KnownRuntimeEnvironmentSchema.safeParse(accessed).success).toBe(false) - }) - - it('rejects a generic access link without its SSH dependency', () => { - expect( - KnownRuntimeEnvironmentSchema.safeParse({ - ...accessEnvironment(), - connectionDependency: undefined - }).success - ).toBe(false) - }) - - it('rejects a non-WebSocket loopback endpoint', () => { - const accessed = accessEnvironment() - accessed.endpoints[1]!.endpoint = 'https://127.0.0.1:46768' - expect(KnownRuntimeEnvironmentSchema.safeParse(accessed).success).toBe(false) - }) -}) diff --git a/src/shared/runtime-environments.ts b/src/shared/runtime-environments.ts index f5c57c178890..b3411eb3f701 100644 --- a/src/shared/runtime-environments.ts +++ b/src/shared/runtime-environments.ts @@ -1,7 +1,5 @@ import { z } from 'zod' import { PAIRING_OFFER_VERSION, type PairingOffer } from './pairing' -import { classifyRemotePairingHostname } from './remote-pairing-address' -import { RuntimeEnvironmentReconciliationRecordSchema } from './runtime-environment-reconciliation-record' export const RuntimeAccessEndpointSchema = z.object({ id: z.string().min(1), @@ -22,38 +20,7 @@ export type PublicRuntimeAccessEndpoint = z.infer -export const RuntimeSshTunnelLinkSchema = z.object({ - sshTargetId: z.string().min(1), - sshTargetGeneration: z.number().int().positive(), - localPort: z.number().int().min(1).max(65_535), - remotePort: z.number().int().min(1).max(65_535) -}) - -export type RuntimeSshTunnelLink = z.infer - -export const RuntimeSshAccessLinkSchema = RuntimeSshTunnelLinkSchema.extend({ - requestId: z.string().min(1).optional(), - targetFingerprint: z.string().min(1).optional(), - endpointId: z.string().min(1), - previousPreferredEndpointId: z.string().min(1) -}) - -export type RuntimeSshAccessLink = z.infer - -export const RuntimeSshAccessOperationSchema = RuntimeSshTunnelLinkSchema.omit({ localPort: true }) - .extend({ - requestId: z.string().min(1), - operation: z.enum(['link', 'unlink']), - targetFingerprint: z.string().min(1).optional() - }) - .refine((intent) => intent.operation !== 'link' || !!intent.targetFingerprint, { - message: 'Link intent requires a target fingerprint.' - }) - -export type RuntimeSshAccessOperation = z.infer - -/** The fields shipped builds read and rewrite in orca-environments.json. */ -export const PersistedRuntimeEnvironmentSchema = z.object({ +export const KnownRuntimeEnvironmentSchema = z.object({ id: z.string().min(1), name: z.string().min(1), createdAt: z.number().finite(), @@ -68,46 +35,6 @@ export const PersistedRuntimeEnvironmentSchema = z.object({ preferredEndpointId: z.string().min(1) }) -export type PersistedRuntimeEnvironment = z.infer - -/** - * A persisted environment with its sidecar state overlaid (runtime-environment-sidecar). These - * fields never enter orca-environments.json: shipped builds strip unknown keys when they rewrite it. - */ -export const KnownRuntimeEnvironmentSchema = PersistedRuntimeEnvironmentSchema.extend({ - sshAccess: RuntimeSshAccessLinkSchema.optional(), - pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(), - reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional() -}) - .refine( - ({ pendingSshAccessOperation, sshAccess, connectionDependency }) => - !pendingSshAccessOperation || (!sshAccess && !connectionDependency), - { - message: 'Pending SSH access operations cannot coexist with active SSH access.', - path: ['pendingSshAccessOperation'] - } - ) - .refine( - (environment) => { - const access = environment.sshAccess - return ( - !access || - (environment.connectionDependency === 'ssh-tunnel' && - environment.preferredEndpointId === access.endpointId && - access.previousPreferredEndpointId !== access.endpointId && - environment.endpoints.some( - (endpoint) => endpoint.id === access.previousPreferredEndpointId - ) && - getPreferredLoopbackRuntimePort(environment) === access.localPort) - ) - }, - { - message: - 'Runtime SSH access must preserve its previous endpoint and prefer its loopback endpoint.', - path: ['sshAccess'] - } - ) - export type KnownRuntimeEnvironment = z.infer export type PublicKnownRuntimeEnvironment = Omit & { @@ -125,11 +52,9 @@ export function redactRuntimeEnvironment( } } -// Why version 1 and the persisted schema: shipped builds accept only this shape, so every -// T5 field lives in the sidecar and a downgrade rewrite cannot lose or reject it. export const RuntimeEnvironmentStoreSchema = z.object({ version: z.literal(1), - environments: z.array(PersistedRuntimeEnvironmentSchema) + environments: z.array(KnownRuntimeEnvironmentSchema) }) export type RuntimeEnvironmentStore = z.infer @@ -196,35 +121,3 @@ export function getPreferredPairingOffer(environment: KnownRuntimeEnvironment): ...(environment.pairedDeviceId ? { pairedDeviceId: environment.pairedDeviceId } : {}) } } - -/** SSH is an access path for a paired server; managed lifecycle ownership is T6's deployment link. */ -export function getRuntimeSshAccess( - environment: Pick -): RuntimeSshTunnelLink | undefined { - return environment.sshAccess -} - -export function getPreferredLoopbackRuntimePort(environment: { - endpoints: { id: string; endpoint: string }[] - preferredEndpointId: string -}): number | null { - const endpoint = environment.endpoints.find( - (entry) => entry.id === environment.preferredEndpointId - ) - if (!endpoint) { - return null - } - try { - const url = new URL(endpoint.endpoint) - const port = Number(url.port) - return (url.protocol === 'ws:' || url.protocol === 'wss:') && - classifyRemotePairingHostname(url.hostname) === 'loopback' && - Number.isInteger(port) && - port >= 1 && - port <= 65_535 - ? port - : null - } catch { - return null - } -} From a0df1c014a1a06100f1ff5709cd1a4c6ec9cbae9 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:03 -0700 Subject: [PATCH 09/26] Revert "feat(relay): capability-gated owner reset with a durable preparation journal (#16741 T3 R1) (#24418)" This reverts commit 34a582bd399de814f3f0a7c47aa3e394d13bc773. --- src/relay/relay-daemon.ts | 82 ++-- src/relay/relay-grace-lifecycle.test.ts | 152 ------- src/relay/relay-grace-lifecycle.ts | 93 +---- src/relay/relay-owner-reset-dispatch.test.ts | 168 -------- ...ay-owner-reset-preparation-journal.test.ts | 134 ------- .../relay-owner-reset-preparation-journal.ts | 113 ------ .../relay-owner-reset-registration.test.ts | 188 --------- src/relay/relay-owner-reset-registration.ts | 75 ---- src/relay/relay-owner-reset.test.ts | 377 ------------------ src/relay/relay-owner-reset.ts | 224 ----------- ...set-preparation-reader.integration.test.ts | 98 ----- .../relay-reset-preparation-reader.test.ts | 104 ----- src/relay/relay-reset-preparation-reader.ts | 76 ---- src/relay/relay.ts | 9 - src/shared/relay-owner-reset-contract.test.ts | 25 -- src/shared/relay-owner-reset-contract.ts | 94 ----- .../relay-reset-preparation-contract.test.ts | 90 ----- .../relay-reset-preparation-contract.ts | 118 ------ src/shared/relay-work-drain-contract.ts | 8 +- 19 files changed, 42 insertions(+), 2186 deletions(-) delete mode 100644 src/relay/relay-grace-lifecycle.test.ts delete mode 100644 src/relay/relay-owner-reset-dispatch.test.ts delete mode 100644 src/relay/relay-owner-reset-preparation-journal.test.ts delete mode 100644 src/relay/relay-owner-reset-preparation-journal.ts delete mode 100644 src/relay/relay-owner-reset-registration.test.ts delete mode 100644 src/relay/relay-owner-reset-registration.ts delete mode 100644 src/relay/relay-owner-reset.test.ts delete mode 100644 src/relay/relay-owner-reset.ts delete mode 100644 src/relay/relay-reset-preparation-reader.integration.test.ts delete mode 100644 src/relay/relay-reset-preparation-reader.test.ts delete mode 100644 src/relay/relay-reset-preparation-reader.ts delete mode 100644 src/shared/relay-owner-reset-contract.test.ts delete mode 100644 src/shared/relay-owner-reset-contract.ts delete mode 100644 src/shared/relay-reset-preparation-contract.test.ts delete mode 100644 src/shared/relay-reset-preparation-contract.ts diff --git a/src/relay/relay-daemon.ts b/src/relay/relay-daemon.ts index f9018000352a..649fde3427a4 100644 --- a/src/relay/relay-daemon.ts +++ b/src/relay/relay-daemon.ts @@ -1,8 +1,4 @@ -import { join } from 'node:path' import { installRelayLogRotation } from './rotating-log-writer' -import { registerRelayOwnerReset } from './relay-owner-reset-registration' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' -import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' import { readLaunchVersion } from './relay-handshake' import type { RelayLaunchOptions } from './relay-launch-options' import { RELAY_EMPTY_DETACHED_STARTUP_GRACE_MS, RELAY_IDLE_GRACE_MS } from './relay-launch-options' @@ -94,24 +90,6 @@ export async function runRelayDaemon(options: RelayLaunchOptions): Promise } } ) - // Beside the relay endpoint so a prepared reset survives a daemon restart. - const resetJournal = new RelayOwnerResetPreparationJournal( - join( - options.endpointDir ?? endpointDirForRelaySocket(options.sockPath), - 'owner-reset-preparations' - ), - options.sockPath, - launchVersion - ) - const readOwnerResetStatus = registerRelayOwnerReset(primaryChannel.dispatcher, { - owners: runtime.ptyConsumerSessionAdapter, - lifecycle, - persistPrepared: (request, principal, authenticationKind, assertAuthority) => - resetJournal.persist(request, principal, authenticationKind, assertAuthority), - describePreparation: (principal, authenticationKind) => - resetJournal.describe(principal, authenticationKind), - socket: socketOwnership - }) const startedAt = Date.now() registerRelayStatus( primaryChannel, @@ -120,8 +98,7 @@ export async function runRelayDaemon(options: RelayLaunchOptions): Promise socketOwnership, lifecycle, options, - startedAt, - readOwnerResetStatus + startedAt ) try { @@ -170,37 +147,32 @@ function registerRelayStatus( socketOwnership: RelaySocketOwnership, lifecycle: RelayGraceLifecycle, options: RelayLaunchOptions, - startedAt: number, - readOwnerResetStatus: ReturnType + startedAt: number ): void { - primaryChannel.dispatcher.onRequest('relay.status', async (_params, context) => { - const resetStatus = readOwnerResetStatus(context) - return { - ...resetStatus, - capabilities: [...SKILL_RELAY_CAPABILITIES, ...resetStatus.capabilities], - pid: process.pid, - uptimeMs: Date.now() - startedAt, - detached: options.detached, - stdoutAlive: primaryChannel.isAlive, - memory: process.memoryUsage(), - ptys: { active: runtime.ptyHandler.activePtyCount }, - ptySourceCredit: { - enabled: true, - session: runtime.ptyConsumerSessionAdapter.getDebugSnapshot(), - publication: runtime.ptySourcePublication.getDebugSnapshot() - }, - socket: { - path: options.sockPath, - owned: socketOwnership.owned, - listening: socketOwnership.server?.listening ?? false, - clients: reconnectListener.clientCount, - acceptedConnections: reconnectListener.acceptedConnections - }, - grace: { - active: runtime.ptyHandler.graceTimerActive, - deadlineAt: lifecycle.deadlineAt, - reason: lifecycle.reason - } + primaryChannel.dispatcher.onRequest('relay.status', async () => ({ + capabilities: SKILL_RELAY_CAPABILITIES, + pid: process.pid, + uptimeMs: Date.now() - startedAt, + detached: options.detached, + stdoutAlive: primaryChannel.isAlive, + memory: process.memoryUsage(), + ptys: { active: runtime.ptyHandler.activePtyCount }, + ptySourceCredit: { + enabled: true, + session: runtime.ptyConsumerSessionAdapter.getDebugSnapshot(), + publication: runtime.ptySourcePublication.getDebugSnapshot() + }, + socket: { + path: options.sockPath, + owned: socketOwnership.owned, + listening: socketOwnership.server?.listening ?? false, + clients: reconnectListener.clientCount, + acceptedConnections: reconnectListener.acceptedConnections + }, + grace: { + active: runtime.ptyHandler.graceTimerActive, + deadlineAt: lifecycle.deadlineAt, + reason: lifecycle.reason } - }) + })) } diff --git a/src/relay/relay-grace-lifecycle.test.ts b/src/relay/relay-grace-lifecycle.test.ts deleted file mode 100644 index 499d87dd34c5..000000000000 --- a/src/relay/relay-grace-lifecycle.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import type { RelayDispatcher, RequestContext } from './dispatcher' -import type { PtyHandler } from './pty-handler' -import { RelayGraceLifecycle } from './relay-grace-lifecycle' - -afterEach(() => { - vi.restoreAllMocks() -}) - -function fixture(options: { clients?: number; dispose?: () => Promise } = {}) { - const graceCallbacks: (() => void)[] = [] - const ptyHandler = { - configuredGraceTimeMs: 1_000, - activePtyCount: 0, - pendingPtyCreationCount: 0, - graceTimerActive: false, - startGraceTimer: vi.fn((callback: () => void) => graceCallbacks.push(callback)), - cancelGraceTimer: vi.fn(), - onPtyPoolEmpty: vi.fn(() => () => {}), - onPtyPoolActive: vi.fn(() => () => {}), - dispose: vi.fn(options.dispose ?? (async () => {})) - } - const dispatcher = { - onNotification: vi.fn(), - onRequest: vi.fn(), - assertActiveWorkContext: vi.fn(), - beginWorkDrain: vi.fn(async () => {}) - } - const disposeOwnedProcesses = vi.fn(async () => {}) - const disposeRuntime = vi.fn() - const lifecycle = new RelayGraceLifecycle({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every dispatcher member the lifecycle calls. - dispatcher: dispatcher as unknown as RelayDispatcher, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every PTY handler member the lifecycle calls. - ptyHandler: ptyHandler as unknown as PtyHandler, - detached: true, - emptyDetachedStartupGraceMs: 100, - idleRelayGraceMs: 100, - readSocketClientCount: () => options.clients ?? 0, - hasAcceptedSocketClient: () => false, - ownsSocketPath: () => true, - disposeOwnedProcesses, - disposeRuntime - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: process.exit never returns; the stub only records the call. - const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) - return { - lifecycle, - ptyHandler, - dispatcher, - disposeOwnedProcesses, - disposeRuntime, - exit, - graceCallbacks - } -} - -it('exits after idle shutdown without waiting on admitted requests', async () => { - const f = fixture() - f.lifecycle.shutdown() - await vi.waitFor(() => expect(f.exit).toHaveBeenCalledWith(0)) - expect(f.ptyHandler.dispose).toHaveBeenCalledOnce() - expect(f.disposeOwnedProcesses).toHaveBeenCalledOnce() - expect(f.disposeRuntime).toHaveBeenCalledOnce() - expect(f.dispatcher.beginWorkDrain).not.toHaveBeenCalled() -}) - -it('defers a failed idle shutdown and retries it through the grace timer', async () => { - let attempts = 0 - const f = fixture({ - dispose: async () => { - attempts += 1 - if (attempts === 1) { - throw new Error('pty still exiting') - } - } - }) - vi.spyOn(console, 'error').mockImplementation(() => {}) - f.lifecycle.shutdown() - await vi.waitFor(() => expect(f.ptyHandler.startGraceTimer).toHaveBeenCalledOnce()) - expect(f.lifecycle.reason).toBe('shutdown deferred') - expect(f.exit).not.toHaveBeenCalled() - - f.graceCallbacks[0]() - await vi.waitFor(() => expect(f.exit).toHaveBeenCalledWith(0)) - expect(f.ptyHandler.dispose).toHaveBeenCalledTimes(2) -}) - -it('does not retry a deferred shutdown while a socket client is attached', async () => { - const f = fixture({ - clients: 1, - dispose: async () => { - throw new Error('pty still exiting') - } - }) - f.lifecycle.shutdown() - await vi.waitFor(() => expect(f.ptyHandler.dispose).toHaveBeenCalledOnce()) - await Promise.resolve() - expect(f.ptyHandler.startGraceTimer).not.toHaveBeenCalled() - expect(f.exit).not.toHaveBeenCalled() -}) - -it('keeps the transport up until a reset initiator finishes, draining around owned disposal', async () => { - const f = fixture() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle only forwards the initiator to the dispatcher stub. - const initiator = { clientId: 1 } as RequestContext - await f.lifecycle.prepareShutdown(initiator) - expect(f.dispatcher.assertActiveWorkContext).toHaveBeenCalledWith(initiator) - expect(f.dispatcher.beginWorkDrain).toHaveBeenCalledTimes(2) - expect(f.dispatcher.beginWorkDrain).toHaveBeenCalledWith(initiator) - expect(f.disposeOwnedProcesses).toHaveBeenCalledOnce() - expect(f.disposeRuntime).not.toHaveBeenCalled() - expect(f.exit).not.toHaveBeenCalled() - - f.lifecycle.finishShutdown() - expect(f.disposeRuntime).toHaveBeenCalledOnce() - expect(f.exit).toHaveBeenCalledWith(0) -}) - -it('joins the same initiator, refuses a different one, and requires preparation to finish', async () => { - const f = fixture() - expect(() => f.lifecycle.finishShutdown()).toThrow('relay_shutdown_preparation_required') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle only forwards the initiator to the dispatcher stub. - const initiator = { clientId: 1 } as RequestContext - const first = f.lifecycle.prepareShutdown(initiator) - expect(f.lifecycle.prepareShutdown(initiator)).toBe(first) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle only forwards the initiator to the dispatcher stub. - const other = { clientId: 2 } as RequestContext - await expect(f.lifecycle.prepareShutdown(other)).rejects.toThrow( - 'relay_shutdown_preparation_in_progress' - ) - await first - expect(f.ptyHandler.dispose).toHaveBeenCalledOnce() -}) - -it('lets a failed reset preparation be retried', async () => { - let attempts = 0 - const f = fixture({ - dispose: async () => { - attempts += 1 - if (attempts === 1) { - throw new Error('pty still exiting') - } - } - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle only forwards the initiator to the dispatcher stub. - const initiator = { clientId: 1 } as RequestContext - await expect(f.lifecycle.prepareShutdown(initiator)).rejects.toThrow('pty still exiting') - expect(f.disposeOwnedProcesses).not.toHaveBeenCalled() - await f.lifecycle.prepareShutdown(initiator) - expect(f.disposeOwnedProcesses).toHaveBeenCalledOnce() -}) diff --git a/src/relay/relay-grace-lifecycle.ts b/src/relay/relay-grace-lifecycle.ts index b32578fa9d4d..116b88731151 100644 --- a/src/relay/relay-grace-lifecycle.ts +++ b/src/relay/relay-grace-lifecycle.ts @@ -6,7 +6,7 @@ import { } from './relay-grace-branch' import { relayLogLine } from './relay-diagnostic-log' import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../shared/ssh-types' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher } from './dispatcher' type RelayGraceLifecycleOptions = { dispatcher: RelayDispatcher @@ -26,9 +26,6 @@ export class RelayGraceLifecycle { private graceReason: string | null = null private graceBranch: RelayGraceBranch | null = null private shutdownInFlight = false - private shutdownPreparation: Promise | null = null - private shutdownPrepared = false - private shutdownInitiator: RequestContext | undefined private stopPoolWatch = (): void => {} private stopPoolActiveWatch = (): void => {} @@ -116,11 +113,22 @@ export class RelayGraceLifecycle { if (this.shutdownInFlight) { return } + this.shutdownInFlight = true relayLogLine( `[relay] Shutdown: ptys=${this.options.ptyHandler.activePtyCount}, clients=${this.options.readSocketClientCount()}, ownsSocket=${this.options.ownsSocketPath()}` ) - void this.prepareShutdown() - .then(() => this.finishShutdown()) + this.graceDeadlineAt = null + this.graceReason = null + this.graceBranch = null + void this.options.ptyHandler + .dispose() + .then(async () => { + await this.options.disposeOwnedProcesses() + this.stopPoolWatch() + this.stopPoolActiveWatch() + this.options.disposeRuntime() + process.exit(0) + }) .catch((error) => { this.shutdownInFlight = false relayLogLine( @@ -132,79 +140,6 @@ export class RelayGraceLifecycle { }) } - /** - * Disposes PTYs and owned processes but leaves the transport up, so a host-owned reset can - * settle its response before {@link finishShutdown} exits. Only a reset initiator also drains - * admitted requests; idle and signal shutdown keep exiting without waiting on them. - */ - prepareShutdown(initiator?: RequestContext, onAdmitted?: () => void): Promise { - if (initiator) { - try { - this.options.dispatcher.assertActiveWorkContext(initiator) - } catch (error) { - return Promise.reject(error) - } - } - if (this.shutdownPreparation) { - if (initiator && initiator !== this.shutdownInitiator) { - return Promise.reject(new Error('relay_shutdown_preparation_in_progress')) - } - return this.shutdownPreparation - } - try { - onAdmitted?.() - } catch (error) { - return Promise.reject(error) - } - this.shutdownInFlight = true - this.shutdownInitiator = initiator - let drainage: Promise - let disposal: Promise - try { - this.cancel('shutdown preparation') - drainage = initiator ? this.options.dispatcher.beginWorkDrain(initiator) : Promise.resolve() - disposal = this.options.ptyHandler.dispose() - } catch (error) { - this.shutdownInFlight = false - this.shutdownInitiator = undefined - return Promise.reject(error) - } - const preparation = Promise.allSettled([drainage, disposal]).then(async (results) => { - const failures = results.flatMap((result) => - result.status === 'rejected' ? [result.reason] : [] - ) - if (failures.length === 1) { - throw failures[0] - } - if (failures.length > 1) { - throw new AggregateError(failures, 'relay_shutdown_admitted_work_incomplete') - } - await this.options.disposeOwnedProcesses() - if (initiator) { - // Cleanup controls may have arrived while owned producers were settling. - await this.options.dispatcher.beginWorkDrain(initiator) - } - this.shutdownPrepared = true - }) - this.shutdownPreparation = preparation.catch((error: unknown) => { - this.shutdownPreparation = null - this.shutdownInitiator = undefined - this.shutdownInFlight = false - throw error - }) - return this.shutdownPreparation - } - - finishShutdown(): void { - if (!this.shutdownPrepared) { - throw new Error('relay_shutdown_preparation_required') - } - this.stopPoolWatch() - this.stopPoolActiveWatch() - this.options.disposeRuntime() - process.exit(0) - } - private configure(params: Record): { graceTimeMs: number } { return applyRelayGraceTimeConfiguration(params.graceTimeSeconds, { readConfiguredGraceMs: () => this.options.ptyHandler.configuredGraceTimeMs, diff --git a/src/relay/relay-owner-reset-dispatch.test.ts b/src/relay/relay-owner-reset-dispatch.test.ts deleted file mode 100644 index b3fa8fef34f3..000000000000 --- a/src/relay/relay-owner-reset-dispatch.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' -import { RelayGraceLifecycle } from './relay-grace-lifecycle' -import { RelayOwnerReset } from './relay-owner-reset' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import type { PtyHandler } from './pty-handler' -import { encodeJsonRpcFrame } from './protocol' -import { RELAY_PREPARED_RESET_RECOVERY_METHOD } from '../shared/relay-owner-reset-contract' - -let dispatcher: RelayDispatcher | undefined -afterEach(() => { - dispatcher?.dispose() - vi.restoreAllMocks() -}) - -it.each(['same-transport', 'reconnect', 'new-coordinator'])( - 'replays prepared reset through %s without repeating real lifecycle disposal', - async (mode) => { - const writes: { - message: Record - settle: (result: SinkWriteSettlement) => void - }[] = [] - const write: ConstructorParameters[0] = (data, settle) => { - const length = data.readUInt32BE(9) - writes.push({ message: JSON.parse(data.subarray(13, 13 + length).toString()), settle }) - return true - } - const identity = { - principal: 'owner', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' as const - } - dispatcher = new RelayDispatcher(write, { supportsWriteCallback: true }, identity) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build') - const dispose = vi.fn(async () => {}) - const owned = vi.fn(async () => {}) - const runtime = vi.fn() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: process.exit never returns; the stub only records the call. - const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) - const lifecycle = new RelayGraceLifecycle({ - dispatcher, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every PTY handler member the lifecycle calls. - ptyHandler: { - cancelGraceTimer: vi.fn(), - dispose - } as unknown as PtyHandler, - detached: true, - emptyDetachedStartupGraceMs: 100, - idleRelayGraceMs: 100, - readSocketClientCount: () => 1, - hasAcceptedSocketClient: () => true, - ownsSocketPath: () => true, - disposeOwnedProcesses: owned, - disposeRuntime: runtime - }) - const reset = new RelayOwnerReset({ owners: adapter, lifecycle, ownsEndpoint: () => true }) - dispatcher.onRequest('relay.reset', (params, context) => reset.prepare(params, context)) - dispatcher.onRequest(RELAY_PREPARED_RESET_RECOVERY_METHOD, async (params, context) => - reset.recoverPrepared(params, context) - ) - const mutation = vi.fn(async () => {}) - dispatcher.onRequest('fs.writeFile', mutation) - let seq = 0 - let clientId = dispatcher.activeClientIds()[0] - const send = (method: string, params: Record, id?: number) => - dispatcher!.feedClient( - clientId, - encodeJsonRpcFrame( - { jsonrpc: '2.0', method, params, ...(id === undefined ? {} : { id }) }, - ++seq, - 0 - ) - ) - send( - 'pty.openClient', - { protocolVersion: 1, clientInstanceId: 'desktop', requestedRole: 'session-owner' }, - 1 - ) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 1)).toBe(true)) - const grant = writes.find((write) => write.message.id === 1)! - grant.settle({ ok: true }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pty.openClient's grant is a plain JSON record on this wire. - const owner = grant.message.result as Record - const params = { - version: 1, - runtimeIncarnation: reset.runtimeIncarnation, - operationId: 'reset-1', - ownerGeneration: owner.ownerGeneration, - ownerLease: owner.ownerLease - } - send('relay.reset', params, 2) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 2)).toBe(true)) - expect(dispose).toHaveBeenCalledOnce() - expect(owned).toHaveBeenCalledOnce() - send('rpc.cancel', { id: 2 }) - writes.find((write) => write.message.id === 2)!.settle({ ok: true }) - expect(exit).not.toHaveBeenCalled() - if (mode !== 'same-transport') { - dispatcher.invalidateClient('peer-closed') - clientId = dispatcher.attachClient(write, { supportsWriteCallback: true }, identity) - expect(adapter.activeSessionOwner(clientId)).toBeNull() - send( - 'pty.openClient', - { - protocolVersion: 1, - clientInstanceId: 'desktop', - requestedRole: 'session-owner' - }, - 22 - ) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 22)).toBe(true)) - const fencedGrant = writes.find((write) => write.message.id === 22)! - expect(fencedGrant.message.error).toMatchObject({ message: 'relay_work_admission_closed' }) - fencedGrant.settle({ ok: true }) - expect(adapter.activeSessionOwner(clientId)).toBeNull() - if (mode === 'new-coordinator') { - const replacement = new RelayOwnerReset({ - owners: adapter, - lifecycle, - ownsEndpoint: () => true - }) - expect(replacement.runtimeIncarnation).not.toBe(reset.runtimeIncarnation) - dispatcher.onRequest(RELAY_PREPARED_RESET_RECOVERY_METHOD, async (params, context) => - replacement.recoverPrepared(params, context) - ) - for (const [id, runtimeIncarnation] of [ - [20, reset.runtimeIncarnation], - [21, replacement.runtimeIncarnation] - ] as const) { - send(RELAY_PREPARED_RESET_RECOVERY_METHOD, { ...params, runtimeIncarnation }, id) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === id)).toBe(true)) - const refusal = writes.find((write) => write.message.id === id)! - expect(refusal.message.error).toMatchObject({ - message: 'relay_reset_continuation_unauthorized' - }) - refusal.settle({ ok: true }) - expect(exit).not.toHaveBeenCalled() - } - dispatcher.onRequest(RELAY_PREPARED_RESET_RECOVERY_METHOD, async (params, context) => - reset.recoverPrepared(params, context) - ) - } - } - send( - mode === 'same-transport' ? 'relay.reset' : RELAY_PREPARED_RESET_RECOVERY_METHOD, - params, - 3 - ) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 3)).toBe(true)) - const retry = writes.find((write) => write.message.id === 3)! - expect(retry.message.result).toEqual({ - version: 1, - operationId: params.operationId, - runtimeIncarnation: reset.runtimeIncarnation, - prepared: true - }) - expect(dispose).toHaveBeenCalledOnce() - expect(owned).toHaveBeenCalledOnce() - send('fs.writeFile', {}, 4) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 4)).toBe(true)) - expect(mutation).not.toHaveBeenCalled() - expect(runtime).not.toHaveBeenCalled() - retry.settle({ ok: true }) - expect(runtime).toHaveBeenCalledOnce() - expect(exit).toHaveBeenCalledWith(0) - } -) diff --git a/src/relay/relay-owner-reset-preparation-journal.test.ts b/src/relay/relay-owner-reset-preparation-journal.test.ts deleted file mode 100644 index 2a572aa1bd80..000000000000 --- a/src/relay/relay-owner-reset-preparation-journal.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { mkdtempSync, rmSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { tmpdir } from 'node:os' -import { beforeEach, afterEach, expect, it, vi } from 'vitest' -import * as secure from '../shared/secure-file' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' - -let directory: string -const request = { - version: 1 as const, - operationId: 'operation', - runtimeIncarnation: 'runtime', - ownerGeneration: 1, - ownerLease: 'secret' -} -const store = () => new RelayOwnerResetPreparationJournal(directory, '/socket', 'build') -const persist = () => store().persist(request, 'principal', 'endpoint-credential', () => {}) -beforeEach(() => { - directory = mkdtempSync(join(tmpdir(), 'orca-reset-preparation-')) -}) -afterEach(() => { - vi.restoreAllMocks() - rmSync(directory, { recursive: true, force: true }) -}) - -it('advertises an absolute journal location without creating files', () => { - const journal = new RelayOwnerResetPreparationJournal('relative-journal', '/socket', 'build') - expect(journal.describe('owner', 'endpoint-credential')).toMatchObject({ - journalDirectory: resolve('relative-journal'), - readerVersion: 1, - principal: 'owner', - sockPath: '/socket', - serverBuildId: 'build' - }) -}) - -it('retains exact preparation across store recreation without claiming process exit', () => { - expect(store().read(request)).toBeNull() - persist() - const record = store().read(request) - expect(record).toEqual({ - version: 1, - prepared: true, - request, - principal: 'principal', - authenticationKind: 'endpoint-credential', - sockPath: '/socket', - serverBuildId: 'build' - }) - expect(Object.isFrozen(record)).toBe(true) - expect(Object.isFrozen(record!.request)).toBe(true) - expect(readdirSync(directory)[0]).not.toContain('secret') -}) - -it('reflushes exact retry after a write published but its return was lost', () => { - const write = secure.writeDurableSecureJsonFile - const spy = vi - .spyOn(secure, 'writeDurableSecureJsonFile') - .mockImplementationOnce((path, record) => { - write(path, record) - throw new Error('return lost') - }) - expect(persist).toThrow('return lost') - expect(store().read(request)?.prepared).toBe(true) - persist() - expect(spy).toHaveBeenCalledTimes(2) -}) - -it('does not overwrite a conflicting principal or operation owner', () => { - persist() - const path = join(directory, readdirSync(directory)[0]) - const original = readFileSync(path, 'utf8') - expect(() => store().persist(request, 'other', 'endpoint-credential', () => {})).toThrow( - 'conflict' - ) - expect(() => - store().persist( - { ...request, ownerLease: 'other' }, - 'principal', - 'endpoint-credential', - () => {} - ) - ).toThrow('conflict') - expect(readFileSync(path, 'utf8')).toBe(original) -}) - -it('refuses a different socket or build even with matching operation identifiers', () => { - persist() - expect(() => - new RelayOwnerResetPreparationJournal(directory, '/other', 'build').read(request) - ).toThrow('conflict') - expect(() => - new RelayOwnerResetPreparationJournal(directory, '/socket', 'other').read(request) - ).toThrow('conflict') -}) - -it('does not grant preparation when the secure write cannot be confirmed', () => { - vi.spyOn(secure, 'writeDurableSecureJsonFile').mockReturnValue(false) - expect(persist).toThrow('write_unconfirmed') - expect(store().read(request)).toBeNull() -}) - -it('preserves corrupt evidence rather than overwriting it on retry', () => { - persist() - const path = join(directory, readdirSync(directory)[0]) - writeFileSync(path, '{broken') - expect(persist).toThrow() - expect(readFileSync(path, 'utf8')).toBe('{broken') -}) - -it('refuses reentrant publication through a second store instance', () => { - const authority = () => expect(persist).toThrow('busy') - store().persist(request, 'principal', 'endpoint-credential', authority) - expect(store().read(request)?.prepared).toBe(true) -}) - -it('checks authority before any write and again after publication', () => { - const write = vi.spyOn(secure, 'writeDurableSecureJsonFile') - expect(() => - store().persist(request, 'principal', 'endpoint-credential', () => { - throw new Error('stale') - }) - ).toThrow('stale') - expect(write).not.toHaveBeenCalled() - let checks = 0 - expect(() => - store().persist(request, 'principal', 'endpoint-credential', () => { - if (++checks === 3) { - throw new Error('stale after write') - } - }) - ).toThrow('stale after write') - expect(store().read(request)?.prepared).toBe(true) -}) diff --git a/src/relay/relay-owner-reset-preparation-journal.ts b/src/relay/relay-owner-reset-preparation-journal.ts deleted file mode 100644 index b22c750cabaa..000000000000 --- a/src/relay/relay-owner-reset-preparation-journal.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { createHash } from 'node:crypto' -import { lstatSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../shared/node-bounded-file-reader' -import { writeDurableSecureJsonFile } from '../shared/secure-file' -import { - parseRelayOwnerResetRequest, - type RelayOwnerResetRequest -} from '../shared/relay-owner-reset-contract' -import { - parseRelayResetPreparationBinding, - parseRelayResetPreparationRecord as parsePreparation, - type RelayResetPreparationRecord as Preparation -} from '../shared/relay-reset-preparation-contract' -const writing = new Set() - -/** Immutable preparation evidence, not evidence that the daemon or its processes exited. */ -export class RelayOwnerResetPreparationJournal { - private readonly directory: string - - constructor( - directory: string, - private readonly sockPath: string, - private readonly serverBuildId: string - ) { - this.directory = resolve(directory) - } - - describe(principal: string, authenticationKind: string) { - return parseRelayResetPreparationBinding({ - version: 1, - journalDirectory: this.directory, - readerVersion: 1, - sockPath: this.sockPath, - serverBuildId: this.serverBuildId, - principal, - authenticationKind - }) - } - - private path(request: RelayOwnerResetRequest): string { - const hash = createHash('sha256') - .update(JSON.stringify([request.runtimeIncarnation, request.operationId])) - .digest('hex') - return join(this.directory, `${hash}.json`) - } - - read(value: RelayOwnerResetRequest): Preparation | null { - const request = parseRelayOwnerResetRequest(value) - const path = this.path(request) - try { - const stat = lstatSync(path) - if (!stat.isFile() || stat.size > 64 * 1024) { - throw new Error('relay_reset_preparation_journal_invalid') - } - const record = parsePreparation( - JSON.parse(readNodeFileSyncWithinLimit(path, 64 * 1024).buffer.toString('utf8')) - ) - if ( - JSON.stringify(record.request) !== JSON.stringify(request) || - record.sockPath !== this.sockPath || - record.serverBuildId !== this.serverBuildId - ) { - throw new Error('relay_reset_preparation_journal_conflict') - } - return record - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return null - } - throw error - } - } - - persist( - request: RelayOwnerResetRequest, - principal: string, - authenticationKind: string, - assertAuthority: () => void - ): undefined { - const record = parsePreparation({ - version: 1, - prepared: true, - request, - principal, - authenticationKind, - sockPath: this.sockPath, - serverBuildId: this.serverBuildId - }) - const path = this.path(record.request) - if (writing.has(path)) { - throw new Error('relay_reset_preparation_journal_busy') - } - writing.add(path) - try { - assertAuthority() - const previous = this.read(record.request) - if (previous && JSON.stringify(previous) !== JSON.stringify(record)) { - throw new Error('relay_reset_preparation_journal_conflict') - } - assertAuthority() - if (!writeDurableSecureJsonFile(path, record)) { - throw new Error('relay_reset_preparation_journal_write_unconfirmed') - } - assertAuthority() - if (JSON.stringify(this.read(record.request)) !== JSON.stringify(record)) { - throw new Error('relay_reset_preparation_journal_write_unconfirmed') - } - } finally { - writing.delete(path) - } - } -} diff --git a/src/relay/relay-owner-reset-registration.test.ts b/src/relay/relay-owner-reset-registration.test.ts deleted file mode 100644 index dc757b4db99f..000000000000 --- a/src/relay/relay-owner-reset-registration.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { Server } from 'node:net' -import type { MethodHandler, RequestContext } from './dispatcher' -import { registerRelayOwnerReset } from './relay-owner-reset-registration' -import { parseRelayResetPreparationBinding } from '../shared/relay-reset-preparation-contract' -import { - RELAY_OWNER_RESET_CAPABILITY, - RELAY_DURABLE_RESET_PREPARATION_CAPABILITY, - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD -} from '../shared/relay-owner-reset-contract' - -function connection(clientId = 1) { - let callback: Parameters>[0] | undefined - const context: RequestContext = { - clientId, - transportGeneration: clientId, - isStale: () => false, - sessionIdentity: { - principal: 'owner', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' - }, - onResponseSettled: (settle) => { - callback = settle - } - } - return { context, settle: () => callback!({ ok: true }) } -} - -function fixture( - runtimeIncarnation?: string, - persistPrepared?: Parameters[1]['persistPrepared'], - describePreparation?: Parameters[1]['describePreparation'] -) { - const methods = new Map() - const owner = { ownerGeneration: 1, ownerLease: 'lease' } - const owners = { - activeSessionOwner: vi.fn((): typeof owner | null => owner), - assertOwnerPublicationSettled: vi.fn() - } - const lifecycle = { - prepareShutdown: vi.fn(async (_context?: RequestContext, admitted?: () => void) => { - admitted?.() - }), - finishShutdown: vi.fn() - } - const server = { listening: true } - const socket = { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub exposes only the listening flag the registration reads. - server: server as Server | null, - ownsCurrentPath: vi.fn(() => true) - } - const status = registerRelayOwnerReset( - { onRequest: (name, handler) => methods.set(name, handler) }, - { owners, lifecycle, socket, runtimeIncarnation, persistPrepared, describePreparation } - ) - const params = { - version: 1, - operationId: 'reset-1', - runtimeIncarnation: status().ownerReset!.runtimeIncarnation, - ...owner - } - const call = (method: string, context: RequestContext) => methods.get(method)!(params, context) - return { methods, owners, lifecycle, server, socket, status, params, call } -} - -it('publishes coordinates bound to the requesting authenticated owner only', () => { - const describe = vi.fn((principal: string, authenticationKind: string) => - parseRelayResetPreparationBinding({ - version: 1, - journalDirectory: '/journal', - sockPath: '/socket', - serverBuildId: 'build', - principal, - authenticationKind - }) - ) - const f = fixture(undefined, () => undefined, describe) - const { context } = connection() - expect(f.status(context).ownerReset?.preparation).toMatchObject({ - principal: 'owner', - journalDirectory: '/journal' - }) - expect(f.status().ownerReset?.preparation).toBeUndefined() - expect(f.status({ ...context, isStale: () => true }).ownerReset?.preparation).toBeUndefined() - expect( - f.status({ ...context, sessionIdentity: undefined }).ownerReset?.preparation - ).toBeUndefined() - describe.mockClear() - f.server.listening = false - expect(f.status(context)).toEqual({ capabilities: [] }) - expect(describe).not.toHaveBeenCalled() -}) - -it('advertises durable preparation only when the journal writer is installed', () => { - expect(fixture().status().capabilities).not.toContain(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY) - const f = fixture(undefined, () => undefined) - expect(f.status().capabilities).toContain(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY) - f.server.listening = false - expect(f.status().capabilities).not.toContain(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY) -}) - -it('registers both operations before advertising a stable incarnation', () => { - const f = fixture() - expect([...f.methods.keys()]).toEqual([ - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD - ]) - expect(f.status()).toEqual({ - capabilities: [RELAY_OWNER_RESET_CAPABILITY], - ownerReset: { version: 1, runtimeIncarnation: expect.any(String) } - }) - expect(f.status()).toEqual(f.status()) - expect(f.params.runtimeIncarnation).not.toBe('') -}) - -it('uses the daemon incarnation shared with network tunnel ownership', () => { - const f = fixture('shared-daemon-incarnation') - expect(f.status().ownerReset?.runtimeIncarnation).toBe('shared-daemon-incarnation') -}) - -it.each(['missing-server', 'not-listening', 'lost-path'])( - 'withdraws capability and refuses initial reset on %s', - async (failure) => { - const f = fixture() - if (failure === 'missing-server') { - f.socket.server = null - } else if (failure === 'not-listening') { - f.server.listening = false - } else { - f.socket.ownsCurrentPath.mockReturnValue(false) - } - expect(f.status()).toEqual({ capabilities: [] }) - await expect(f.call(RELAY_OWNER_RESET_METHOD, connection().context)).rejects.toThrow() - expect(f.lifecycle.prepareShutdown).not.toHaveBeenCalled() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - } -) - -it('reuses the same incarnation and prepared result across authenticated reconnect', async () => { - const f = fixture() - const result = await f.call(RELAY_OWNER_RESET_METHOD, connection().context) - f.owners.activeSessionOwner.mockReturnValue(null) - const recovered = connection(2) - await expect(f.call(RELAY_PREPARED_RESET_RECOVERY_METHOD, recovered.context)).resolves.toEqual( - result - ) - expect(f.status().ownerReset!.runtimeIncarnation).toBe(f.params.runtimeIncarnation) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(1) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - recovered.settle() - expect(f.lifecycle.finishShutdown).toHaveBeenCalledTimes(1) -}) - -it.each(['initial', 'recovery'] as const)( - 'refuses %s settlement after endpoint ownership disappears', - async (mode) => { - const f = fixture() - const initial = connection() - await f.call(RELAY_OWNER_RESET_METHOD, initial.context) - const response = mode === 'initial' ? initial : connection(2) - if (mode === 'recovery') { - await f.call(RELAY_PREPARED_RESET_RECOVERY_METHOD, response.context) - } - f.socket.ownsCurrentPath.mockReturnValue(false) - expect(() => response.settle()).toThrow() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - expect(f.status()).toEqual({ capabilities: [] }) - } -) - -it('refuses prepared recovery when endpoint stops listening and allows exact retry after restoration', async () => { - const f = fixture() - await f.call(RELAY_OWNER_RESET_METHOD, connection().context) - f.server.listening = false - await expect( - f.call(RELAY_PREPARED_RESET_RECOVERY_METHOD, connection(2).context) - ).rejects.toThrow() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - f.server.listening = true - const retry = connection(3) - await f.call(RELAY_PREPARED_RESET_RECOVERY_METHOD, retry.context) - retry.settle() - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(1) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledTimes(1) -}) diff --git a/src/relay/relay-owner-reset-registration.ts b/src/relay/relay-owner-reset-registration.ts deleted file mode 100644 index ae3b7e6b189c..000000000000 --- a/src/relay/relay-owner-reset-registration.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { RelayDispatcher, RequestContext } from './dispatcher' -import type { RelayResetPreparationBinding } from '../shared/relay-reset-preparation-contract' -import type { RelayGraceLifecycle } from './relay-grace-lifecycle' -import type { RelaySocketOwnership } from './relay-socket-ownership' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import { RelayOwnerReset } from './relay-owner-reset' -import { - RELAY_OWNER_RESET_CAPABILITY, - RELAY_DURABLE_RESET_PREPARATION_CAPABILITY, - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD -} from '../shared/relay-owner-reset-contract' - -export function registerRelayOwnerReset( - dispatcher: Pick, - options: { - runtimeIncarnation?: string - owners: Pick< - SshPtyConsumerSessionAdapter, - 'activeSessionOwner' | 'assertOwnerPublicationSettled' - > - lifecycle: Pick - persistPrepared?: ConstructorParameters[0]['persistPrepared'] - describePreparation?: ( - principal: string, - authenticationKind: string - ) => RelayResetPreparationBinding - socket: Pick - } -): (context?: RequestContext) => { - capabilities: string[] - ownerReset?: { - version: 1 - runtimeIncarnation: string - preparation?: RelayResetPreparationBinding - } -} { - const ownsEndpoint = () => - options.socket.server?.listening === true && options.socket.ownsCurrentPath() - const reset = new RelayOwnerReset({ ...options, ownsEndpoint }) - dispatcher.onRequest(RELAY_OWNER_RESET_METHOD, (params, context) => - reset.prepare(params, context) - ) - dispatcher.onRequest(RELAY_PREPARED_RESET_RECOVERY_METHOD, async (params, context) => - reset.recoverPrepared(params, context) - ) - return (context) => { - if (!ownsEndpoint()) { - return { capabilities: [] } - } - const identity = context?.sessionIdentity - const preparation = - options.persistPrepared && - identity?.authenticated && - identity.allowSessionOwner && - ['launch-nonce', 'endpoint-credential'].includes(identity.authenticationKind) && - !context!.isStale() && - !context!.signal?.aborted - ? options.describePreparation?.(identity.principal, identity.authenticationKind) - : undefined - return ownsEndpoint() - ? { - capabilities: [ - RELAY_OWNER_RESET_CAPABILITY, - ...(options.persistPrepared ? [RELAY_DURABLE_RESET_PREPARATION_CAPABILITY] : []) - ], - ownerReset: { - version: 1, - runtimeIncarnation: reset.runtimeIncarnation, - ...(preparation ? { preparation } : {}) - } - } - : { capabilities: [] } - } -} diff --git a/src/relay/relay-owner-reset.test.ts b/src/relay/relay-owner-reset.test.ts deleted file mode 100644 index 4b7449be98a3..000000000000 --- a/src/relay/relay-owner-reset.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { RequestContext } from './dispatcher' -import { RelayOwnerReset } from './relay-owner-reset' - -function fixture( - persistPrepared?: ConstructorParameters[0]['persistPrepared'] -) { - let settlement: Parameters>[0] | undefined - const context: RequestContext = { - clientId: 1, - isStale: () => false, - sessionIdentity: { - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential', - principal: 'owner' - }, - onResponseSettled: (callback) => { - settlement = callback - } - } - const owner = { ownerGeneration: 1, ownerLease: 'lease' } - const owners = { - activeSessionOwner: vi.fn((): typeof owner | null => owner), - assertOwnerPublicationSettled: vi.fn() - } - const lifecycle = { - prepareShutdown: vi.fn(async (_context?: RequestContext, admitted?: () => void) => { - admitted?.() - }), - finishShutdown: vi.fn() - } - const ownsEndpoint = vi.fn(() => true) - const reset = new RelayOwnerReset({ owners, lifecycle, ownsEndpoint, persistPrepared }) - const params = { - version: 1, - operationId: 'reset-1', - runtimeIncarnation: reset.runtimeIncarnation, - ...owner - } - return { - context, - owners, - lifecycle, - ownsEndpoint, - reset, - params, - settle: (ok: boolean) => - settlement?.(ok ? { ok: true } : { ok: false, error: new Error('write failed') }) - } -} - -it('withholds acknowledgment on journal failure and reflushes without repeating preparation', async () => { - const persist = vi - .fn[0]['persistPrepared']>>() - .mockImplementationOnce(() => { - throw new Error('disk unavailable') - }) - .mockReturnValue(undefined) - const f = fixture(persist) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('disk unavailable') - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - await expect(f.reset.prepare(f.params, f.context)).resolves.toMatchObject({ prepared: true }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(1) - expect(persist).toHaveBeenCalledTimes(2) - expect(persist).toHaveBeenLastCalledWith( - f.params, - 'owner', - 'endpoint-credential', - expect.any(Function) - ) - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledTimes(1) -}) - -it('prepared continuation also requires a confirmed journal reflush before acknowledgment', async () => { - const persist = vi - .fn[0]['persistPrepared']>>() - .mockReturnValue(undefined) - const f = fixture(persist) - await f.reset.prepare(f.params, f.context) - f.settle(false) - persist.mockImplementationOnce(() => { - throw new Error('journal uncertain') - }) - expect(() => f.reset.recoverPrepared(f.params, { ...f.context, clientId: 2 })).toThrow( - 'journal uncertain' - ) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - expect(f.reset.recoverPrepared(f.params, { ...f.context, clientId: 3 })).toMatchObject({ - prepared: true - }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(1) - expect(persist).toHaveBeenCalledTimes(3) -}) - -it('rejects an asynchronous journal hook rather than acknowledging before durability', async () => { - const write = vi.fn(() => Promise.resolve()) - const f = fixture( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub records writes; the dispatcher only calls it as a function. - write as unknown as NonNullable< - ConstructorParameters[0]['persistPrepared'] - > - ) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('not_synchronous') - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() -}) - -it('finishes only after successful preparation and successful response settlement', async () => { - const f = fixture() - const pending = Promise.withResolvers() - f.lifecycle.prepareShutdown.mockReturnValue(pending.promise) - const result = f.reset.prepare(f.params, f.context) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledWith(f.context, expect.any(Function)) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - pending.resolve() - await expect(result).resolves.toEqual({ - version: 1, - operationId: 'reset-1', - runtimeIncarnation: f.reset.runtimeIncarnation, - prepared: true - }) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - f.settle(true) - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledOnce() -}) - -it.each(['unauthenticated', 'subscriber', 'stale', 'endpoint', 'lease', 'callback'])( - 'refuses %s admission before destructive preparation', - async (failure) => { - const f = fixture() - if (failure === 'unauthenticated') { - f.context.sessionIdentity!.authenticated = false - } - if (failure === 'subscriber') { - f.owners.activeSessionOwner.mockReturnValue(null) - } - if (failure === 'stale') { - f.context.isStale = () => true - } - if (failure === 'endpoint') { - f.ownsEndpoint.mockReturnValue(false) - } - if (failure === 'lease') { - f.params.ownerLease = 'another' - } - if (failure === 'callback') { - f.context.onResponseSettled = () => { - throw new Error('registration failed') - } - } - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow() - expect(f.lifecycle.prepareShutdown).not.toHaveBeenCalled() - } -) - -it('does not finish on failed preparation even when its error response is written', async () => { - const f = fixture() - f.lifecycle.prepareShutdown.mockRejectedValue(new Error('transfer fenced')) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('transfer fenced') - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() -}) - -it('does not infer successful reset from a lost response', async () => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - f.settle(false) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() -}) - -it('recovers only a retained prepared result on a fresh authenticated transport without ownership admission', async () => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - f.settle(false) - f.owners.activeSessionOwner.mockReturnValue(null) - const context = { ...f.context, clientId: 2, transportGeneration: 2 } - expect(f.reset.recoverPrepared(f.params, context)).toMatchObject({ prepared: true }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledOnce() -}) - -it.each(['missing', 'pending', 'failed'])( - 'refuses %s preparation without invoking cleanup from recovery', - async (state) => { - const f = fixture() - const pending = Promise.withResolvers() - let initial: Promise | undefined - if (state === 'pending') { - f.lifecycle.prepareShutdown.mockReturnValue(pending.promise) - initial = f.reset.prepare(f.params, f.context) - } else if (state === 'failed') { - f.lifecycle.prepareShutdown.mockImplementationOnce(async (_context, admitted) => { - admitted?.() - throw new Error('failed') - }) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('failed') - } - const calls = f.lifecycle.prepareShutdown.mock.calls.length - expect(() => f.reset.recoverPrepared(f.params, { ...f.context, clientId: 2 })).toThrow( - 'relay_reset_continuation_unauthorized' - ) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(calls) - pending.resolve() - await initial - } -) - -it.each(['incarnation', 'lease', 'principal', 'endpoint', 'unauthenticated'])( - 'rejects changed %s in reconnect continuation', - async (changed) => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - const params = { ...f.params } - if (changed === 'incarnation') { - params.runtimeIncarnation = 'old-process' - } - if (changed === 'lease') { - params.ownerLease = 'wrong' - } - if (changed === 'principal') { - f.context.sessionIdentity!.principal = 'other' - } - if (changed === 'endpoint') { - f.ownsEndpoint.mockReturnValue(false) - } - if (changed === 'unauthenticated') { - f.context.sessionIdentity!.authenticated = false - } - expect(() => f.reset.recoverPrepared(params, { ...f.context, clientId: 2 })).toThrow( - 'relay_reset_continuation_unauthorized' - ) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - } -) - -it('rechecks the recovery transport at response settlement and preserves later recovery', async () => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - let stale = false - f.reset.recoverPrepared(f.params, { ...f.context, clientId: 2, isStale: () => stale }) - stale = true - expect(() => f.settle(true)).toThrow('relay_reset_continuation_unauthorized') - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - f.reset.recoverPrepared(f.params, { ...f.context, clientId: 3 }) - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledOnce() -}) - -it('replays a prepared same-transport retry without repeating destructive cleanup', async () => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - f.settle(false) - await expect(f.reset.prepare(f.params, { ...f.context })).resolves.toMatchObject({ - prepared: true - }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledOnce() -}) - -it('refuses pending duplicates without waiting on the first request', async () => { - const f = fixture() - const pending = Promise.withResolvers() - f.lifecycle.prepareShutdown.mockReturnValue(pending.promise) - const first = f.reset.prepare(f.params, f.context) - await expect(f.reset.prepare(f.params, { ...f.context })).rejects.toThrow( - 'relay_reset_preparation_in_progress' - ) - pending.resolve() - await first - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() -}) - -it.each(['operation', 'transport', 'principal'])( - 'refuses changed %s on prepared replay', - async (changed) => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - f.settle(false) - if (changed === 'operation') { - f.params.operationId = 'different' - } else if (changed === 'transport') { - f.context.transportGeneration = 2 - } else { - f.context.sessionIdentity!.principal = 'different' - } - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow( - 'relay_reset_operation_conflict' - ) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - } -) - -it('retries failed cleanup explicitly instead of replaying it as prepared', async () => { - const f = fixture() - f.lifecycle.prepareShutdown.mockRejectedValueOnce(new Error('cleanup failed')) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('cleanup failed') - await f.reset.prepare(f.params, { ...f.context }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(2) -}) - -it('releases an operation refused before admission so a new operation can proceed', async () => { - const f = fixture() - f.lifecycle.prepareShutdown.mockRejectedValueOnce(new Error('transfer fenced')) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('transfer fenced') - f.params.operationId = 'new-reset' - await expect(f.reset.prepare(f.params, f.context)).resolves.toMatchObject({ - operationId: 'new-reset' - }) -}) - -it('retains an admitted operation through cleanup failure and a later pre-admission refusal', async () => { - const f = fixture() - f.lifecycle.prepareShutdown.mockImplementationOnce(async (_context, admitted) => { - admitted?.() - throw new Error('cleanup failed') - }) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('cleanup failed') - f.lifecycle.prepareShutdown.mockRejectedValueOnce(new Error('early retry refusal')) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('early retry refusal') - await expect( - f.reset.prepare({ ...f.params, operationId: 'different' }, f.context) - ).rejects.toThrow('relay_reset_operation_conflict') - await f.reset.prepare(f.params, f.context) -}) - -it('allows an explicit prepared replay to retry final shutdown after it throws', async () => { - const f = fixture() - f.lifecycle.finishShutdown.mockImplementationOnce(() => { - throw new Error('finish failed') - }) - await f.reset.prepare(f.params, f.context) - expect(() => f.settle(true)).toThrow('finish failed') - await f.reset.prepare(f.params, { ...f.context }) - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledTimes(2) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() -}) - -it('refuses a queued replacement grant before starting shutdown', async () => { - const f = fixture() - f.owners.assertOwnerPublicationSettled.mockImplementation(() => { - throw new Error('pty_consumer_owner_publication_pending') - }) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow( - 'pty_consumer_owner_publication_pending' - ) - expect(f.lifecycle.prepareShutdown).not.toHaveBeenCalled() - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() -}) - -it.each(['owner', 'endpoint'])( - 'refuses completion if %s changes during preparation', - async (changed) => { - const f = fixture() - const pending = Promise.withResolvers() - f.lifecycle.prepareShutdown.mockReturnValue(pending.promise) - const result = f.reset.prepare(f.params, f.context) - if (changed === 'owner') { - f.owners.activeSessionOwner.mockReturnValue(null) - } else { - f.ownsEndpoint.mockReturnValue(false) - } - pending.resolve() - await expect(result).rejects.toThrow('relay_reset_unauthorized') - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - } -) diff --git a/src/relay/relay-owner-reset.ts b/src/relay/relay-owner-reset.ts deleted file mode 100644 index 829d342e3f31..000000000000 --- a/src/relay/relay-owner-reset.ts +++ /dev/null @@ -1,224 +0,0 @@ -import type { RequestContext } from './dispatcher' -import { randomUUID } from 'node:crypto' -import type { RelayGraceLifecycle } from './relay-grace-lifecycle' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import { - parseRelayOwnerResetRequest, - type RelayOwnerResetRequest, - type RelayOwnerResetAcknowledgment -} from '../shared/relay-owner-reset-contract' - -type ResetOptions = { - runtimeIncarnation?: string - owners: Pick - lifecycle: Pick - ownsEndpoint: () => boolean - persistPrepared?: ( - request: RelayOwnerResetRequest, - principal: string, - authenticationKind: string, - assertAuthority: () => void - ) => undefined -} - -/** Prepared continuation never authorizes new cleanup or ownership admission. */ -export class RelayOwnerReset { - readonly runtimeIncarnation: string - private finished = false - private admitted: { - request: RelayOwnerResetRequest - principal: string - authenticationKind: string - clientId: number - transportGeneration: number | undefined - state: 'pending' | 'prepared' | 'failed' - } | null = null - - constructor(private readonly options: ResetOptions) { - this.runtimeIncarnation = options.runtimeIncarnation ?? randomUUID() - } - - recoverPrepared( - params: Record, - context: RequestContext - ): RelayOwnerResetAcknowledgment { - const request = parseRelayOwnerResetRequest(params) - const expectedIncarnation = request.runtimeIncarnation - const retained = this.admitted - const clientId = context.clientId - const generation = context.transportGeneration - const assertContinuation = (): void => { - const identity = context.sessionIdentity - if ( - !retained || - this.admitted !== retained || - retained.state !== 'prepared' || - expectedIncarnation !== this.runtimeIncarnation || - request.operationId !== retained.request.operationId || - request.ownerGeneration !== retained.request.ownerGeneration || - request.ownerLease !== retained.request.ownerLease || - identity?.authenticated !== true || - identity.allowSessionOwner !== true || - identity.principal !== retained.principal || - identity.authenticationKind !== retained.authenticationKind || - context.isStale() || - context.signal?.aborted || - context.clientId !== clientId || - context.transportGeneration !== generation || - !context.onResponseSettled || - !this.options.ownsEndpoint() - ) { - throw new Error('relay_reset_continuation_unauthorized') - } - } - assertContinuation() - const persisted = this.options.persistPrepared?.( - request, - retained!.principal, - retained!.authenticationKind, - assertContinuation - ) - if (persisted !== undefined) { - throw new Error('relay_reset_preparation_journal_not_synchronous') - } - assertContinuation() - context.onResponseSettled!((settlement) => { - if (!settlement.ok || this.finished) { - return - } - assertContinuation() - this.finish() - }) - assertContinuation() - return { - version: 1, - operationId: request.operationId, - runtimeIncarnation: this.runtimeIncarnation, - prepared: true - } - } - - private finish(): void { - this.finished = true - try { - this.options.lifecycle.finishShutdown() - } catch (error) { - this.finished = false - throw error - } - } - - async prepare( - params: Record, - context: RequestContext - ): Promise { - const request = parseRelayOwnerResetRequest(params) - if (request.runtimeIncarnation !== this.runtimeIncarnation) { - throw new Error('relay_reset_incarnation_mismatch') - } - const principal = context.sessionIdentity?.principal - const authenticationKind = context.sessionIdentity?.authenticationKind - const clientId = context.clientId - const transportGeneration = context.transportGeneration - const assertOwner = (): void => { - const identity = context.sessionIdentity - const owner = this.options.owners.activeSessionOwner(context.clientId) - if ( - context.isStale() || - context.signal?.aborted || - identity?.authenticated !== true || - identity.allowSessionOwner !== true || - !['launch-nonce', 'endpoint-credential'].includes(identity.authenticationKind) || - !principal || - identity.principal !== principal || - identity.authenticationKind !== authenticationKind || - context.clientId !== clientId || - context.transportGeneration !== transportGeneration || - !context.onResponseSettled || - !this.options.ownsEndpoint() || - owner?.ownerGeneration !== request.ownerGeneration || - owner.ownerLease !== request.ownerLease - ) { - throw new Error('relay_reset_unauthorized') - } - } - assertOwner() - const previous = this.admitted - if (previous) { - if ( - previous.request.operationId !== request.operationId || - previous.request.ownerGeneration !== request.ownerGeneration || - previous.request.ownerLease !== request.ownerLease || - previous.principal !== principal || - previous.authenticationKind !== context.sessionIdentity!.authenticationKind || - previous.clientId !== context.clientId || - previous.transportGeneration !== context.transportGeneration - ) { - throw new Error('relay_reset_operation_conflict') - } - if (previous.state === 'pending') { - throw new Error('relay_reset_preparation_in_progress') - } - } - let prepared = false - let preparationRecord: typeof this.admitted = null - context.onResponseSettled!((settlement) => { - if ( - !prepared || - !settlement.ok || - this.finished || - !preparationRecord || - this.admitted !== preparationRecord || - preparationRecord.state !== 'prepared' - ) { - return - } - assertOwner() - this.finish() - }) - assertOwner() - this.options.owners.assertOwnerPublicationSettled() - if (previous?.state !== 'prepared') { - const admitted = previous ?? { - request, - principal: principal!, - authenticationKind: context.sessionIdentity!.authenticationKind, - clientId: context.clientId, - transportGeneration: context.transportGeneration, - state: 'pending' as const - } - this.admitted = admitted - admitted.state = 'pending' - let began = false - try { - await this.options.lifecycle.prepareShutdown(context, () => { - began = true - }) - admitted.state = 'prepared' - } catch (error) { - if (!began && !previous && this.admitted === admitted) { - this.admitted = null - } else { - admitted.state = 'failed' - } - throw error - } - } - assertOwner() - if ( - this.options.persistPrepared?.(request, principal!, authenticationKind!, assertOwner) !== - undefined - ) { - throw new Error('relay_reset_preparation_journal_not_synchronous') - } - assertOwner() - preparationRecord = this.admitted - prepared = true - return { - version: 1, - operationId: request.operationId, - runtimeIncarnation: this.runtimeIncarnation, - prepared: true - } - } -} diff --git a/src/relay/relay-reset-preparation-reader.integration.test.ts b/src/relay/relay-reset-preparation-reader.integration.test.ts deleted file mode 100644 index 10a2cdc8ee68..000000000000 --- a/src/relay/relay-reset-preparation-reader.integration.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { build } from 'esbuild' -import { mkdtempSync, mkdirSync, readdirSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { beforeAll, afterAll, describe, expect, it } from 'vitest' -import { runProcess } from '../shared/child-process/run-process' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' -import { RELAY_RESET_PREPARATION_READ_FLAG } from './relay-reset-preparation-reader' - -let directory: string -let entry: string -let emptyPath: string -let envelope: object -beforeAll(async () => { - directory = mkdtempSync(join(tmpdir(), 'orca-reset-reader-bundle-')) - entry = join(directory, 'relay.js') - emptyPath = join(directory, 'empty-path') - mkdirSync(emptyPath) - const journal = new RelayOwnerResetPreparationJournal( - join(directory, 'journal'), - join(directory, 'never-created.sock'), - 'old-build' - ) - const request = { - version: 1 as const, - operationId: 'reset', - runtimeIncarnation: 'exited-daemon', - ownerGeneration: 1, - ownerLease: 'private-lease' - } - journal.persist(request, 'owner', 'endpoint-credential', () => {}) - envelope = { version: 1, binding: journal.describe('owner', 'endpoint-credential'), request } - await build({ - entryPoints: [join(__dirname, 'relay.ts')], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: entry, - external: ['node-pty', '@parcel/watcher', 'electron'], - sourcemap: false - }) -}, 30_000) -afterAll(() => { - if (directory) { - rmSync(directory, { recursive: true, force: true }) - } -}) - -describe('Node built reader', () => { - const program = process.execPath - it('reads retained evidence in a fresh process with an empty PATH and no daemon artifacts', async () => { - const before = readdirSync(directory).sort() - const result = await runProcess({ - program, - args: [entry, RELAY_RESET_PREPARATION_READ_FLAG], - cwd: directory, - input: JSON.stringify(envelope), - timeoutMs: 15_000, - maxOutputBytes: 128 * 1024, - env: { - ...process.env, - PATH: emptyPath, - NODE_PATH: '', - NODE_OPTIONS: '', - ORCA_BACKGROUND_LAUNCH: '1' - } - }) - expect(result.timedOut).toBe(false) - expect(result.code, result.stderr).toBe(0) - expect(JSON.parse(result.stdout)).toMatchObject({ - version: 1, - preparation: { - prepared: true, - serverBuildId: 'old-build', - request: { runtimeIncarnation: 'exited-daemon' } - } - }) - expect(readdirSync(directory).sort()).toEqual(before) - }, 20_000) - - it('rejects mixed launch modes without starting a daemon', async () => { - const before = readdirSync(directory).sort() - const result = await runProcess({ - program, - args: [entry, '--detached', RELAY_RESET_PREPARATION_READ_FLAG], - cwd: directory, - input: JSON.stringify(envelope), - timeoutMs: 15_000, - env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } - }) - expect(result.timedOut).toBe(false) - expect(result.code).toBe(1) - expect(result.stdout).toBe('') - expect(result.stderr).toContain('arguments_invalid') - expect(readdirSync(directory).sort()).toEqual(before) - }, 20_000) -}) diff --git a/src/relay/relay-reset-preparation-reader.test.ts b/src/relay/relay-reset-preparation-reader.test.ts deleted file mode 100644 index 38b66e36b79c..000000000000 --- a/src/relay/relay-reset-preparation-reader.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { PassThrough, Readable } from 'node:stream' -import { beforeEach, afterEach, expect, it, vi } from 'vitest' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' -import { - isRelayResetPreparationReadMode, - readRelayResetPreparation, - readRelayResetPreparationStdin, - RELAY_RESET_PREPARATION_READ_FLAG -} from './relay-reset-preparation-reader' - -let directory: string -const request = { - version: 1 as const, - operationId: 'reset', - runtimeIncarnation: 'runtime', - ownerGeneration: 1, - ownerLease: 'private-owner-lease' -} -const journal = () => new RelayOwnerResetPreparationJournal(directory, '/socket', 'build') -const envelope = () => ({ - version: 1, - binding: journal().describe('owner', 'endpoint-credential'), - request -}) -beforeEach(() => { - directory = mkdtempSync(join(tmpdir(), 'orca-reset-read-')) -}) -afterEach(() => { - vi.useRealTimers() - rmSync(directory, { recursive: true, force: true }) -}) - -it('requires an exclusive reader mode and rejects combinations before normal mode selection', () => { - expect( - isRelayResetPreparationReadMode(['bun', 'relay.js', RELAY_RESET_PREPARATION_READ_FLAG]) - ).toBe(true) - expect(isRelayResetPreparationReadMode(['bun', 'relay.js', '--connect'])).toBe(false) - for (const extra of ['--connect', '--detached', '--orca-cli', '--sock-path']) { - expect(() => - isRelayResetPreparationReadMode(['bun', 'relay.js', extra, RELAY_RESET_PREPARATION_READ_FLAG]) - ).toThrow('arguments_invalid') - } -}) - -it('reads preparation after writer recreation without changing journal bytes', async () => { - journal().persist(request, 'owner', 'endpoint-credential', () => {}) - const file = join(directory, readdirSync(directory)[0]) - const before = readFileSync(file, 'utf8') - const output = await readRelayResetPreparationStdin(Readable.from([JSON.stringify(envelope())])) - expect(JSON.parse(output)).toEqual({ version: 1, preparation: journal().read(request) }) - expect(readFileSync(file, 'utf8')).toBe(before) - expect(readdirSync(directory)).toHaveLength(1) -}) - -it('reports missing evidence without creating a directory or inventing preparation', async () => { - const value = envelope() - value.binding = { ...value.binding, journalDirectory: join(directory, 'missing') } - expect(readRelayResetPreparation(value)).toEqual({ version: 1, preparation: null }) - expect(readdirSync(directory)).toEqual([]) -}) - -it('requires the exact recorded principal and owner lease', () => { - journal().persist(request, 'owner', 'endpoint-credential', () => {}) - const value = envelope() - expect(() => - readRelayResetPreparation({ ...value, binding: { ...value.binding, principal: 'other' } }) - ).toThrow('conflict') - expect(() => - readRelayResetPreparation({ ...value, request: { ...request, ownerLease: 'other' } }) - ).toThrow('conflict') -}) - -it('refuses relative journal paths rather than depending on the reader cwd', () => { - const value = envelope() - expect(() => - readRelayResetPreparation({ - ...value, - binding: { ...value.binding, journalDirectory: 'relative' } - }) - ).toThrow('directory_invalid') -}) - -it('bounds stdin and suppresses credential-bearing parse errors', async () => { - await expect( - readRelayResetPreparationStdin(Readable.from([Buffer.alloc(64 * 1024 + 1)])) - ).rejects.toThrow('read_failed') - await expect( - readRelayResetPreparationStdin(Readable.from(['private-owner-lease is invalid JSON'])) - ).rejects.toThrow(/^relay_reset_preparation_read_failed$/) -}) - -it('times out a reader whose input never ends', async () => { - vi.useFakeTimers() - const input = new PassThrough() - const result = readRelayResetPreparationStdin(input) - const rejection = expect(result).rejects.toThrow('read_failed') - await vi.advanceTimersByTimeAsync(10_000) - await rejection - expect(input.destroyed).toBe(true) - expect(vi.getTimerCount()).toBe(0) -}) diff --git a/src/relay/relay-reset-preparation-reader.ts b/src/relay/relay-reset-preparation-reader.ts deleted file mode 100644 index a738b9712ccc..000000000000 --- a/src/relay/relay-reset-preparation-reader.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { isAbsolute } from 'node:path' -import type { Readable } from 'node:stream' -import { - parseRelayOwnerResetRequest, - readRelayResetRecord -} from '../shared/relay-owner-reset-contract' -import { - parseRelayResetPreparationBinding, - RELAY_RESET_PREPARATION_READ_FLAG, - validateRelayResetPreparationRecord -} from '../shared/relay-reset-preparation-contract' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' - -export { RELAY_RESET_PREPARATION_READ_FLAG } from '../shared/relay-reset-preparation-contract' -const MAX_INPUT_BYTES = 64 * 1024 - -export function isRelayResetPreparationReadMode(argv: readonly string[]): boolean { - const args = argv.slice(2) - if (!args.includes(RELAY_RESET_PREPARATION_READ_FLAG)) { - return false - } - if (args.length !== 1 || args[0] !== RELAY_RESET_PREPARATION_READ_FLAG) { - throw new Error('relay_reset_preparation_read_arguments_invalid') - } - return true -} - -/** The invoking SSH account supplies OS read authority; no relay session or daemon is started. */ -export function readRelayResetPreparation(value: unknown) { - const envelope = readRelayResetRecord(value) - if (envelope?.version !== 1) { - throw new Error('relay_reset_preparation_read_invalid') - } - const binding = parseRelayResetPreparationBinding(envelope.binding) - const request = parseRelayOwnerResetRequest(envelope.request) - if (!isAbsolute(binding.journalDirectory)) { - throw new Error('relay_reset_preparation_read_directory_invalid') - } - const journal = new RelayOwnerResetPreparationJournal( - binding.journalDirectory, - binding.sockPath, - binding.serverBuildId - ) - const record = journal.read(request) - return { - version: 1 as const, - preparation: record ? validateRelayResetPreparationRecord(record, binding, request) : null - } -} - -export async function readRelayResetPreparationStdin(input: Readable): Promise { - const chunks: Buffer[] = [] - let bytes = 0 - const timeout = setTimeout( - () => input.destroy(new Error('relay_reset_preparation_read_timeout')), - 10_000 - ) - timeout.unref?.() - try { - for await (const value of input) { - const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value) - bytes += chunk.length - if (bytes > MAX_INPUT_BYTES) { - throw new Error('relay_reset_preparation_read_too_large') - } - chunks.push(chunk) - } - const request = JSON.parse(Buffer.concat(chunks).toString('utf8')) - return `${JSON.stringify(readRelayResetPreparation(request))}\n` - } catch { - // Parse and filesystem errors can contain credentials from the request or journal. - throw new Error('relay_reset_preparation_read_failed') - } finally { - clearTimeout(timeout) - } -} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 1ed674057f1e..e986a19b7094 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -11,10 +11,6 @@ import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' import { runRelayRuntimeSelfTestCommand } from './relay-runtime-self-test' import { runRelayWindowsBreakawayLaunchIfRequested } from './relay-windows-breakaway-launch' import { RELAY_RUNTIME_SELF_TEST_FLAG } from '../shared/relay-runtime-self-test-report' -import { - isRelayResetPreparationReadMode, - readRelayResetPreparationStdin -} from './relay-reset-preparation-reader' async function main(): Promise { const selfTestFlag = process.argv.indexOf(RELAY_RUNTIME_SELF_TEST_FLAG) @@ -25,11 +21,6 @@ async function main(): Promise { if (runRelayWindowsBreakawayLaunchIfRequested(process.argv)) { return } - // A read-only exec: the SSH account's own file access is the authority; no daemon starts. - if (isRelayResetPreparationReadMode(process.argv)) { - process.stdout.write(await readRelayResetPreparationStdin(process.stdin)) - return - } const options = parseRelayLaunchOptions(process.argv) if (options.connectMode) { runRelayConnectChannel(options.sockPath, readRelayEndpointCredential(options.credentialFile)) diff --git a/src/shared/relay-owner-reset-contract.test.ts b/src/shared/relay-owner-reset-contract.test.ts deleted file mode 100644 index a52304195b6e..000000000000 --- a/src/shared/relay-owner-reset-contract.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - RELAY_DURABLE_RESET_PREPARATION_CAPABILITY, - RELAY_OWNER_RESET_CAPABILITY, - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD -} from './relay-owner-reset-contract' -import { RELAY_RESET_PREPARATION_READ_FLAG } from './relay-reset-preparation-contract' -import { allowsRelayWorkDuringDrain } from './relay-work-drain-contract' - -// Relays and clients update independently, so these names are permanent once released. -describe('relay owner-reset wire contract', () => { - it('pins the advertised capabilities, methods and reader flag', () => { - expect(RELAY_OWNER_RESET_CAPABILITY).toBe('relay.ownerReset.v1') - expect(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY).toBe('relay.durableResetPreparation.v1') - expect(RELAY_OWNER_RESET_METHOD).toBe('relay.reset') - expect(RELAY_PREPARED_RESET_RECOVERY_METHOD).toBe('relay.recoverPreparedReset') - expect(RELAY_RESET_PREPARATION_READ_FLAG).toBe('--read-reset-preparation') - }) - - it('admits both reset requests during a work drain, so a prepared reset can settle', () => { - expect(allowsRelayWorkDuringDrain(RELAY_OWNER_RESET_METHOD)).toBe(true) - expect(allowsRelayWorkDuringDrain(RELAY_PREPARED_RESET_RECOVERY_METHOD)).toBe(true) - }) -}) diff --git a/src/shared/relay-owner-reset-contract.ts b/src/shared/relay-owner-reset-contract.ts deleted file mode 100644 index 28ceb9a546a9..000000000000 --- a/src/shared/relay-owner-reset-contract.ts +++ /dev/null @@ -1,94 +0,0 @@ -export const RELAY_OWNER_RESET_CAPABILITY = 'relay.ownerReset.v1' -export const RELAY_DURABLE_RESET_PREPARATION_CAPABILITY = 'relay.durableResetPreparation.v1' -export const RELAY_OWNER_RESET_METHOD = 'relay.reset' -export const RELAY_PREPARED_RESET_RECOVERY_METHOD = 'relay.recoverPreparedReset' - -export type RelayOwnerResetRequest = Readonly<{ - version: 1 - operationId: string - runtimeIncarnation: string - ownerGeneration: number - ownerLease: string -}> - -export type RelayOwnerResetAcknowledgment = { - version: 1 - operationId: string - runtimeIncarnation: string - prepared: true -} - -/** A wire value as a plain record; anything else reads as absent. */ -export function readRelayResetRecord(value: unknown): Record | null { - return typeof value === 'object' && value !== null && !Array.isArray(value) ? { ...value } : null -} - -function isBoundedText(field: unknown, max: number): field is string { - return typeof field === 'string' && field.length > 0 && field.length <= max -} - -export function parseRelayOwnerResetRequest(value: unknown): RelayOwnerResetRequest { - const record = readRelayResetRecord(value) - const operationId = record?.operationId - const runtimeIncarnation = record?.runtimeIncarnation - const ownerGeneration = record?.ownerGeneration - const ownerLease = record?.ownerLease - if ( - record?.version !== 1 || - !isBoundedText(operationId, 128) || - !isBoundedText(runtimeIncarnation, 128) || - typeof ownerGeneration !== 'number' || - !Number.isSafeInteger(ownerGeneration) || - ownerGeneration < 1 || - !isBoundedText(ownerLease, 1024) - ) { - throw new Error('relay_reset_invalid_request') - } - return Object.freeze({ - version: 1, - operationId, - runtimeIncarnation, - ownerGeneration, - ownerLease - }) -} - -export function readRelayOwnerResetIncarnation(status: unknown): string { - const value = readRelayResetRecord(status) - const capabilities = value?.capabilities - const ownerReset = readRelayResetRecord(value?.ownerReset) - const incarnation = ownerReset?.runtimeIncarnation - if ( - !Array.isArray(capabilities) || - !capabilities.includes(RELAY_OWNER_RESET_CAPABILITY) || - ownerReset?.version !== 1 || - typeof incarnation !== 'string' || - incarnation.length === 0 || - incarnation.length > 128 - ) { - throw new Error('relay_reset_capability_unavailable') - } - return incarnation -} - -export function parseRelayOwnerResetAcknowledgment( - value: unknown, - expected: RelayOwnerResetRequest -): RelayOwnerResetAcknowledgment { - const result = readRelayResetRecord(value) - if ( - !result || - result.version !== 1 || - result.prepared !== true || - result.operationId !== expected.operationId || - result.runtimeIncarnation !== expected.runtimeIncarnation - ) { - throw new Error('relay_reset_acknowledgment_invalid') - } - return { - version: 1, - operationId: expected.operationId, - runtimeIncarnation: expected.runtimeIncarnation, - prepared: true - } -} diff --git a/src/shared/relay-reset-preparation-contract.test.ts b/src/shared/relay-reset-preparation-contract.test.ts deleted file mode 100644 index 968a116031b6..000000000000 --- a/src/shared/relay-reset-preparation-contract.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { expect, it } from 'vitest' -import { RELAY_DURABLE_RESET_PREPARATION_CAPABILITY } from './relay-owner-reset-contract' -import { - parseRelayResetPreparationBinding, - readRelayResetPreparationBinding, - validateRelayResetPreparationRecord -} from './relay-reset-preparation-contract' - -const binding = parseRelayResetPreparationBinding({ - version: 1, - journalDirectory: '/journal', - principal: 'owner', - authenticationKind: 'endpoint-credential', - sockPath: '/socket', - serverBuildId: 'build' -}) -const request = { - version: 1 as const, - operationId: 'reset', - runtimeIncarnation: 'runtime', - ownerGeneration: 1, - ownerLease: 'lease' -} -const record = { - version: 1, - prepared: true, - request, - principal: binding.principal, - authenticationKind: binding.authenticationKind, - sockPath: binding.sockPath, - serverBuildId: binding.serverBuildId -} - -it('does not infer durable recovery support from unnegotiated metadata', () => { - expect(readRelayResetPreparationBinding({ ownerReset: { preparation: binding } })).toBeUndefined() - expect(readRelayResetPreparationBinding({ capabilities: [] })).toBeUndefined() -}) - -it('requires valid metadata once durable preparation is advertised', () => { - const capabilities = [RELAY_DURABLE_RESET_PREPARATION_CAPABILITY] - expect(() => readRelayResetPreparationBinding({ capabilities })).toThrow('invalid') - expect( - readRelayResetPreparationBinding({ capabilities, ownerReset: { preparation: binding } }) - ).toEqual(binding) -}) - -it('validates exact preparation without adding an exit or cleanup verdict', () => { - const result = validateRelayResetPreparationRecord(record, binding, request) - expect(result).toEqual(record) - expect(Object.isFrozen(result)).toBe(true) - expect(Object.isFrozen(result.request)).toBe(true) -}) - -it('preserves optional reader negotiation without assuming old hosts implement it', () => { - expect(parseRelayResetPreparationBinding(binding).readerVersion).toBeUndefined() - expect(parseRelayResetPreparationBinding({ ...binding, readerVersion: 1 }).readerVersion).toBe(1) - expect(() => parseRelayResetPreparationBinding({ ...binding, readerVersion: 2 })).toThrow( - 'reader_version_invalid' - ) -}) - -it.each(['principal', 'authenticationKind', 'sockPath', 'serverBuildId'] as const)( - 'rejects changed %s evidence', - (field) => { - const value = field === 'authenticationKind' ? 'launch-nonce' : 'other' - expect(() => - validateRelayResetPreparationRecord({ ...record, [field]: value }, binding, request) - ).toThrow('conflict') - } -) - -it.each(['operationId', 'runtimeIncarnation', 'ownerGeneration', 'ownerLease'] as const)( - 'rejects a changed request %s', - (field) => { - const value = field === 'ownerGeneration' ? 2 : 'other' - expect(() => - validateRelayResetPreparationRecord( - { ...record, request: { ...request, [field]: value } }, - binding, - request - ) - ).toThrow('conflict') - } -) - -it.each(['', 'x\0y', 'x'.repeat(8193)])('refuses invalid journal directory', (journalDirectory) => { - expect(() => parseRelayResetPreparationBinding({ ...binding, journalDirectory })).toThrow( - 'invalid' - ) -}) diff --git a/src/shared/relay-reset-preparation-contract.ts b/src/shared/relay-reset-preparation-contract.ts deleted file mode 100644 index 44bc7d5300a2..000000000000 --- a/src/shared/relay-reset-preparation-contract.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - RELAY_DURABLE_RESET_PREPARATION_CAPABILITY, - parseRelayOwnerResetRequest, - readRelayResetRecord, - type RelayOwnerResetRequest -} from './relay-owner-reset-contract' - -export const RELAY_RESET_PREPARATION_READ_FLAG = '--read-reset-preparation' - -export type RelayResetPreparationBinding = Readonly<{ - version: 1 - journalDirectory: string - readerVersion?: 1 - principal: string - authenticationKind: 'launch-nonce' | 'endpoint-credential' - sockPath: string - serverBuildId: string -}> -export type RelayResetPreparationRecord = Readonly< - Omit & { - prepared: true - request: RelayOwnerResetRequest - } -> - -function isJournalText(value: unknown): value is string { - return ( - typeof value === 'string' && value.length > 0 && value.length <= 8192 && !value.includes('\0') - ) -} - -function isAuthenticationKind( - value: unknown -): value is RelayResetPreparationBinding['authenticationKind'] { - return value === 'launch-nonce' || value === 'endpoint-credential' -} - -function parseIdentity(record: Record | null) { - const { principal, sockPath, serverBuildId, authenticationKind } = record ?? {} - if ( - record?.version !== 1 || - !isJournalText(principal) || - !isJournalText(sockPath) || - !isJournalText(serverBuildId) || - !isAuthenticationKind(authenticationKind) - ) { - throw new Error('relay_reset_preparation_journal_invalid') - } - return { version: 1 as const, principal, authenticationKind, sockPath, serverBuildId } -} - -export function parseRelayResetPreparationBinding(value: unknown): RelayResetPreparationBinding { - const record = readRelayResetRecord(value) - const identity = parseIdentity(record) - const directory = record?.journalDirectory - const readerVersion = record?.readerVersion - if (!isJournalText(directory)) { - throw new Error('relay_reset_preparation_journal_invalid') - } - if (readerVersion !== undefined && readerVersion !== 1) { - throw new Error('relay_reset_preparation_reader_version_invalid') - } - return Object.freeze({ - ...identity, - journalDirectory: directory, - ...(readerVersion === undefined ? {} : { readerVersion }) - }) -} - -export function parseRelayResetPreparationRecord(value: unknown): RelayResetPreparationRecord { - const record = readRelayResetRecord(value) - const identity = parseIdentity(record) - if (record?.prepared !== true) { - throw new Error('relay_reset_preparation_journal_invalid') - } - return Object.freeze({ - version: 1, - prepared: true, - request: parseRelayOwnerResetRequest(record.request), - principal: identity.principal, - authenticationKind: identity.authenticationKind, - sockPath: identity.sockPath, - serverBuildId: identity.serverBuildId - }) -} - -export function readRelayResetPreparationBinding( - status: unknown -): RelayResetPreparationBinding | undefined { - const value = readRelayResetRecord(status) - const capabilities = value?.capabilities - if ( - !Array.isArray(capabilities) || - !capabilities.includes(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY) - ) { - return undefined - } - return parseRelayResetPreparationBinding(readRelayResetRecord(value?.ownerReset)?.preparation) -} - -export function validateRelayResetPreparationRecord( - value: unknown, - expectedBinding: RelayResetPreparationBinding, - expectedRequest: RelayOwnerResetRequest -): RelayResetPreparationRecord { - const record = parseRelayResetPreparationRecord(value) - const binding = parseRelayResetPreparationBinding(expectedBinding) - if ( - record.principal !== binding.principal || - record.authenticationKind !== binding.authenticationKind || - record.sockPath !== binding.sockPath || - record.serverBuildId !== binding.serverBuildId || - JSON.stringify(record.request) !== JSON.stringify(parseRelayOwnerResetRequest(expectedRequest)) - ) { - throw new Error('relay_reset_preparation_journal_conflict') - } - return record -} diff --git a/src/shared/relay-work-drain-contract.ts b/src/shared/relay-work-drain-contract.ts index c2902597ec59..5982f7aec087 100644 --- a/src/shared/relay-work-drain-contract.ts +++ b/src/shared/relay-work-drain-contract.ts @@ -1,15 +1,9 @@ import { SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD } from './skill-ssh-relay-contract' -import { - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD -} from './relay-owner-reset-contract' // Why: a drain must still admit the requests and notifications that let in-flight work finish or cancel. -// Network-tunnel (T4) methods join these sets when their contract lands. +// Owner-reset (T3) and network-tunnel (T4) methods join these sets when their contracts land. const drainRequests = new Set([ 'relay.status', - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD, 'fs.unwatchAndWait', 'agent.cancelExec', // Why (not in #16741): it only retires an existing delivery and replays its proof on retry; From 9a615dd89c498694b66b5e318c70b175c8367c06 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:03 -0700 Subject: [PATCH 10/26] Revert "feat(ssh): track connection-manager drains, test probes and provider continuations (#16741 T2 P3+P8a) (#24407)" This reverts commit d53063d2b1641145d78453408235f58d87e58198. --- .../filesystem-import-ssh-remote-existence.ts | 29 --- src/main/ipc/filesystem-import-ssh.ts | 36 ++- .../filesystem-provider-continuations.test.ts | 238 ------------------ .../filesystem/filesystem-write-handlers.ts | 9 +- src/main/ipc/ssh-active-relay-sessions.ts | 7 +- src/main/ipc/ssh-connect-attempt-registry.ts | 41 --- src/main/ipc/ssh-connection-handlers.ts | 14 +- src/main/ipc/ssh-ipc-module-mocks.ts | 1 - src/main/ipc/ssh-renderer-broadcast.ts | 7 +- src/main/ipc/ssh-shutdown-drain.ts | 39 +-- .../ipc/ssh-state-broadcast-fanout.test.ts | 35 --- src/main/ipc/ssh-target-lifecycle-queue.ts | 24 +- ...ssh-test-connection-probe-registry.test.ts | 98 -------- src/main/ipc/ssh-test-probe-presence.test.ts | 36 --- .../worktrees-ssh-provider-authority.test.ts | 94 ------- .../register-detected-worktree-handlers.ts | 50 ++-- .../register-worktree-removal-handlers.ts | 13 +- .../worktree-removal-continuations.test.ts | 98 -------- src/main/providers/ssh-filesystem-dispatch.ts | 10 - src/main/providers/ssh-git-dispatch.ts | 15 -- .../ssh-connection-disconnect-drain.test.ts | 26 -- .../ssh-connection-manager-closure.test.ts | 108 -------- .../ssh-connection-manager-registry.test.ts | 53 ---- .../ssh/ssh-connection-manager-test-probes.ts | 43 ---- src/main/ssh/ssh-connection-manager.test.ts | 50 ---- src/main/ssh/ssh-connection-manager.ts | 137 +--------- .../ssh/ssh-connection-store-test-fixture.ts | 107 -------- src/main/ssh/ssh-connection-store.test.ts | 69 ++++- src/main/ssh/ssh-provider-continuations.ts | 24 -- src/main/ssh/ssh-target-registry.ts | 12 - 30 files changed, 168 insertions(+), 1355 deletions(-) delete mode 100644 src/main/ipc/filesystem-import-ssh-remote-existence.ts delete mode 100644 src/main/ipc/filesystem-provider-continuations.test.ts delete mode 100644 src/main/ipc/ssh-test-connection-probe-registry.test.ts delete mode 100644 src/main/ipc/ssh-test-probe-presence.test.ts delete mode 100644 src/main/ipc/worktrees/removal/worktree-removal-continuations.test.ts delete mode 100644 src/main/ssh/ssh-connection-manager-closure.test.ts delete mode 100644 src/main/ssh/ssh-connection-manager-test-probes.ts delete mode 100644 src/main/ssh/ssh-connection-store-test-fixture.ts delete mode 100644 src/main/ssh/ssh-provider-continuations.ts diff --git a/src/main/ipc/filesystem-import-ssh-remote-existence.ts b/src/main/ipc/filesystem-import-ssh-remote-existence.ts deleted file mode 100644 index e853f291f803..000000000000 --- a/src/main/ipc/filesystem-import-ssh-remote-existence.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { IFilesystemProvider } from '../providers/types' - -/** Whether a remote path exists; only a definite "missing" answer reads as false. */ -export async function remotePathExists( - provider: IFilesystemProvider, - remotePath: string -): Promise { - try { - await provider.stat(remotePath) - return true - } catch (error) { - if (isRemoteMissingError(error)) { - return false - } - throw error - } -} - -function isRemoteMissingError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false - } - return ( - ('code' in error && error.code === 'ENOENT') || - /\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test( - error.message - ) - ) -} diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index 39b2350bb44e..9268c6ece247 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -8,8 +8,6 @@ import type { FileUploadSession, IFilesystemProvider } from '../providers/types' import type { ImportItemResult } from '../../shared/filesystem-import-result-types' import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' -import { runSshProviderContinuation } from '../ssh/ssh-provider-continuations' -import { remotePathExists } from './filesystem-import-ssh-remote-existence' import { captureLocalUploadRoot, preScanSshImportDirectory, @@ -19,12 +17,6 @@ import { // Why: the SSH import path uses SshFilesystemProvider instead of direct SFTP so // system-SSH transports (ProxyCommand/ProxyJump/FIDO2) get the same workflows. export async function importExternalPathsSsh( - ...args: Parameters -): Promise<{ results: ImportItemResult[] }> { - return runSshProviderContinuation(args[2], () => importExternalPathsSshTracked(...args)) -} - -async function importExternalPathsSshTracked( sourcePaths: string[], destDir: string, connectionId: string, @@ -277,3 +269,31 @@ async function ensureDropStagingDir( assertCurrent?.() await provider.createDir(destDir) } + +async function remotePathExists( + provider: IFilesystemProvider, + remotePath: string +): Promise { + try { + await provider.stat(remotePath) + return true + } catch (error) { + if (isRemoteMissingError(error)) { + return false + } + throw error + } +} + +function isRemoteMissingError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const code = (error as NodeJS.ErrnoException).code + return ( + code === 'ENOENT' || + /\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test( + error.message + ) + ) +} diff --git a/src/main/ipc/filesystem-provider-continuations.test.ts b/src/main/ipc/filesystem-provider-continuations.test.ts deleted file mode 100644 index c585d7ac6f67..000000000000 --- a/src/main/ipc/filesystem-provider-continuations.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { IFilesystemProvider } from '../providers/types' -import type { FilesystemHandlerContext } from './filesystem/filesystem-handler-context' -import { hasSshProviderContinuations } from '../ssh/ssh-provider-continuations' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' -import { - advanceSshConnectionGeneration, - resetSshConnectionGenerations -} from '../ssh/ssh-connection-generation' -import { importExternalPathsSsh } from './filesystem-import-ssh' -import { captureLocalUploadRoot, uploadSshImportDirectory } from './filesystem-import-ssh-directory' -import { registerFilesystemWriteHandlers } from './filesystem/filesystem-write-handlers' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - lstat: vi.fn(), - writeFile: vi.fn(), - trashItem: vi.fn(), - resolveAuthorizedPath: vi.fn(), - tryDeleteWslUncPath: vi.fn() -})) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle }, shell: mocks })) -vi.mock('node:fs/promises', () => ({ lstat: mocks.lstat, writeFile: mocks.writeFile })) -vi.mock('./filesystem-auth', () => ({ - authorizeExternalPath: vi.fn(), - resolveAuthorizedPath: mocks.resolveAuthorizedPath -})) -vi.mock('./filesystem-mutations', () => ({ registerFilesystemMutationHandlers: vi.fn() })) -vi.mock('../wsl-unc-delete', () => ({ tryDeleteWslUncPath: mocks.tryDeleteWslUncPath })) -vi.mock('./ssh', () => ({ - getSshConnectionManager: () => ({ - getConnection: () => ({ getState: () => ({ status: 'connected' }) }) - }) -})) -vi.mock('./filesystem-import-ssh-directory', () => ({ - captureLocalUploadRoot: vi.fn(), - preScanSshImportDirectory: vi.fn(), - uploadSshImportDirectory: vi.fn() -})) - -const targetId = 'filesystem-continuation-target' -// The IPC payload fields these handlers read; each test passes the subset its channel needs. -type FilesystemHandlerArgs = Record -const handlers = new Map< - string, - (_event: unknown, args: FilesystemHandlerArgs) => Promise ->() -const fileStat = { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } - -beforeEach(() => { - vi.resetAllMocks() - resetSshConnectionGenerations() - unregisterSshFilesystemProvider(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(false) - handlers.clear() - mocks.handle.mockImplementation((channel, handler) => handlers.set(channel, handler)) - mocks.lstat.mockResolvedValue(fileStat) - mocks.resolveAuthorizedPath.mockImplementation(async (path) => path) - mocks.tryDeleteWslUncPath.mockResolvedValue(false) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the write handlers read no store field on the SSH branch under test. - registerFilesystemWriteHandlers({ store: {} } as FilesystemHandlerContext) -}) - -describe('SSH filesystem mutation continuation settlement', () => { - it.each(['fs:writeFile', 'fs:deletePath'])( - '%s retains removed providers until settlement', - async (channel) => { - const pending = Promise.withResolvers() - const operation = vi.fn(() => { - expect(hasSshProviderContinuations(targetId)).toBe(true) - return pending.promise - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - writeFile: operation, - deletePath: operation - } as unknown as IFilesystemProvider) - const result = handlers.get(channel)!(null, { - connectionId: targetId, - expectedSshTargetId: targetId, - expectedSshConnectionGeneration: 0, - filePath: '/remote/file', - content: 'text', - targetPath: '/remote/file', - recursive: true - }) - unregisterSshFilesystemProvider(targetId) - advanceSshConnectionGeneration(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - expect(hasSshProviderContinuations('other-target')).toBe(false) - pending.resolve() - await result - expect(hasSshProviderContinuations(targetId)).toBe(false) - expect(operation).toHaveBeenCalledWith( - ...(channel === 'fs:writeFile' ? ['/remote/file', 'text'] : ['/remote/file', true]) - ) - } - ) - - it.each(['fs:writeFile', 'fs:deletePath'])( - '%s releases after provider rejection', - async (channel) => { - const pending = Promise.withResolvers() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - writeFile: () => pending.promise, - deletePath: () => pending.promise - } as unknown as IFilesystemProvider) - const result = handlers.get(channel)!(null, { - connectionId: targetId, - expectedSshTargetId: targetId, - expectedSshConnectionGeneration: 0, - filePath: '/remote/file', - targetPath: '/remote/file', - content: '' - }) - const failure = expect(result).rejects.toThrow('late failure') - expect(hasSshProviderContinuations(targetId)).toBe(true) - pending.reject(new Error('late failure')) - await failure - expect(hasSshProviderContinuations(targetId)).toBe(false) - } - ) - - it.each(['fs:writeFile', 'fs:deletePath'])( - '%s leaves local handling untracked', - async (channel) => { - const pending = Promise.withResolvers() - mocks.writeFile.mockReturnValue(pending.promise) - mocks.trashItem.mockReturnValue(pending.promise) - const result = handlers.get(channel)!(null, { - filePath: '/local/file', - targetPath: '/local/file', - content: 'text' - }) - expect(hasSshProviderContinuations(targetId)).toBe(false) - pending.resolve() - await result - expect(channel === 'fs:writeFile' ? mocks.writeFile : mocks.trashItem).toHaveBeenCalled() - expect(hasSshProviderContinuations(targetId)).toBe(false) - } - ) -}) - -describe('complete SSH import continuation settlement', () => { - it('retains a failed directory import through pending rollback', async () => { - const rollback = Promise.withResolvers() - const rollingBack = Promise.withResolvers() - mocks.lstat.mockResolvedValue({ ...fileStat, isDirectory: () => true, isFile: () => false }) - vi.mocked(captureLocalUploadRoot).mockResolvedValue('/source/directory') - vi.mocked(uploadSshImportDirectory).mockRejectedValue(new Error('upload failed')) - const close = vi.fn(() => expect(hasSshProviderContinuations(targetId)).toBe(true)) - const deletePath = vi.fn(() => { - rollingBack.resolve() - return rollback.promise - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - openFileUploadSession: async () => ({ close }), - stat: async () => { - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }, - createDirNoClobber: async () => {}, - deletePath - } as unknown as IFilesystemProvider) - const result = importExternalPathsSsh(['/source/directory'], '/remote', targetId) - await rollingBack.promise - unregisterSshFilesystemProvider(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - expect(close).not.toHaveBeenCalled() - rollback.resolve() - expect((await result).results[0]).toMatchObject({ status: 'failed', reason: 'upload failed' }) - expect(deletePath).toHaveBeenCalledWith('/remote/directory', true) - expect(close).toHaveBeenCalledOnce() - expect(hasSshProviderContinuations(targetId)).toBe(false) - }) - - it('retains tracking across local inspection, provider removal and session close', async () => { - const inspection = Promise.withResolvers() - const inspected = Promise.withResolvers() - const uploaded = Promise.withResolvers() - const upload = Promise.withResolvers() - mocks.lstat.mockImplementation(() => { - inspected.resolve() - return inspection.promise - }) - const close = vi.fn(() => expect(hasSshProviderContinuations(targetId)).toBe(true)) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - openFileUploadSession: async () => ({ - close, - uploadFile: () => { - uploaded.resolve() - return upload.promise - } - }), - stat: async () => { - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - } - } as unknown as IFilesystemProvider) - const result = importExternalPathsSsh(['/source/file'], '/remote', targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - await inspected.promise - unregisterSshFilesystemProvider(targetId) - advanceSshConnectionGeneration(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - inspection.resolve(fileStat) - await uploaded.promise - expect(hasSshProviderContinuations(targetId)).toBe(true) - upload.resolve() - expect((await result).results[0].status).toBe('imported') - expect(close).toHaveBeenCalledOnce() - expect(hasSshProviderContinuations(targetId)).toBe(false) - }) - - it('includes staging preparation and releases on its failure', async () => { - const staging = Promise.withResolvers() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - createDir: () => { - expect(hasSshProviderContinuations(targetId)).toBe(true) - return staging.promise - } - } as unknown as IFilesystemProvider) - const result = importExternalPathsSsh(['/source/file'], '/remote/.orca/drops', targetId, { - ensureDir: true - }) - const failure = expect(result).rejects.toThrow('staging failed') - unregisterSshFilesystemProvider(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - staging.reject(new Error('staging failed')) - await failure - expect(hasSshProviderContinuations(targetId)).toBe(false) - }) -}) diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index 0cc5a921b451..07d4de5b56a9 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -2,7 +2,6 @@ import { ipcMain, shell } from 'electron' import { lstat, writeFile } from 'node:fs/promises' import type { SshMutationExpectation } from '../../../shared/ssh-types' import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation' -import { runSshProviderContinuation } from '../../ssh/ssh-provider-continuations' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { tryDeleteWslUncPath } from '../../wsl-unc-delete' import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth' @@ -27,9 +26,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex ) if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) - return runSshProviderContinuation(args.connectionId, () => - provider.writeFile(args.filePath, args.content) - ) + return provider.writeFile(args.filePath, args.content) } const filePath = await resolveAuthorizedPath(args.filePath, store) try { @@ -64,9 +61,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex ) if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) - return runSshProviderContinuation(args.connectionId, () => - provider.deletePath(args.targetPath, args.recursive) - ) + return provider.deletePath(args.targetPath, args.recursive) } // Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots). const targetPath = await resolveAuthorizedPath(args.targetPath, store, { diff --git a/src/main/ipc/ssh-active-relay-sessions.ts b/src/main/ipc/ssh-active-relay-sessions.ts index 3c12496f7d4f..69b781be41d4 100644 --- a/src/main/ipc/ssh-active-relay-sessions.ts +++ b/src/main/ipc/ssh-active-relay-sessions.ts @@ -1,8 +1,5 @@ import type { SshRelaySession } from '../ssh/ssh-relay-session' -import { - setDirectSshAuthorityResolver, - setSshActiveMultiplexerResolver -} from '../ssh/ssh-target-registry' +import { setSshActiveMultiplexerResolver } from '../ssh/ssh-target-registry' import { setWorktreeRemovalSshHostHomeResolver } from '../worktree-removal-execution-host-route' // One session per SSH target owns the whole relay lifecycle (mux, providers, abort controller, state machine). @@ -25,5 +22,3 @@ export function getActiveSshHostHomeDirectory(targetId: string): string | null { } setWorktreeRemovalSshHostHomeResolver(getActiveSshHostHomeDirectory) - -setDirectSshAuthorityResolver((targetId) => activeSessions.has(targetId)) diff --git a/src/main/ipc/ssh-connect-attempt-registry.ts b/src/main/ipc/ssh-connect-attempt-registry.ts index 5aba526d6832..5a1e36329824 100644 --- a/src/main/ipc/ssh-connect-attempt-registry.ts +++ b/src/main/ipc/ssh-connect-attempt-registry.ts @@ -44,44 +44,3 @@ export const resetRelayInFlight = new Map>() // Why: ssh:testConnection connects then disconnects; suppressing broadcasts during the test avoids worktree cards flashing connected → disconnected. export const testingTargets = new Set() export const testConnectionProbes = new Set>() -const testConnectionProbesByTarget = new Map>>() - -export function hasSshTestConnectionProbes(targetId: string): boolean { - return (testConnectionProbesByTarget.get(targetId)?.size ?? 0) > 0 -} - -export function runSshTestConnectionProbe( - targetId: string, - operation: () => Promise -): Promise { - const probes = testConnectionProbesByTarget.get(targetId) ?? new Set>() - let probe!: Promise - // Publish before connection callbacks can start a reset or shutdown drain. - probe = Promise.resolve() - .then(operation) - .finally(() => { - testConnectionProbes.delete(probe) - probes.delete(probe) - if (probes.size === 0 && testConnectionProbesByTarget.get(targetId) === probes) { - testConnectionProbesByTarget.delete(targetId) - testingTargets.delete(targetId) - credentialRequestedForTarget.delete(targetId) - } - }) - probes.add(probe) - testConnectionProbesByTarget.set(targetId, probes) - testConnectionProbes.add(probe) - testingTargets.add(targetId) - return probe -} - -/** Reserve target admission before joining; failed probes still own cleanup until settled. */ -export async function awaitSshTestConnectionProbes(targetId: string): Promise { - while (true) { - const probes = testConnectionProbesByTarget.get(targetId) - if (!probes?.size) { - return - } - await Promise.allSettled(probes) - } -} diff --git a/src/main/ipc/ssh-connection-handlers.ts b/src/main/ipc/ssh-connection-handlers.ts index 946dfed7bf9f..9ea533c77c30 100644 --- a/src/main/ipc/ssh-connection-handlers.ts +++ b/src/main/ipc/ssh-connection-handlers.ts @@ -23,7 +23,8 @@ import { credentialRequestedForTarget, invalidateConnectAttempt, resetRelayInFlight, - runSshTestConnectionProbe + testConnectionProbes, + testingTargets } from './ssh-connect-attempt-registry' import { connectTarget } from './ssh-connect-flow' import { connectionManager, persistedStore } from './ssh-ipc-context' @@ -265,16 +266,18 @@ export function registerSshConnectionHandlers(): void { } } + testingTargets.add(args.targetId) // Why a tracked promise and not just the id: a probe holds a real transport that no session owns, // so shutdown has to be able to join it before the final drain disconnects what is left. - const probe = runSshTestConnectionProbe(args.targetId, async () => { + const probe = (async () => { // Why: a probe transport opened after the shutdown drain would outlive orderly teardown. assertSshConnectsNotFenced() const conn = await connectionManager!.connect(target) const state = conn.getState() await connectionManager!.disconnect(args.targetId) return state - }) + })() + testConnectionProbes.add(probe) try { return { success: true, state: await probe } } catch (err) { @@ -282,6 +285,11 @@ export function registerSshConnectionHandlers(): void { success: false, error: err instanceof Error ? err.message : String(err) } + } finally { + testConnectionProbes.delete(probe) + testingTargets.delete(args.targetId) + // Why: clear so a test's credential prompt doesn't leave lastRequiredPassphrase=true and defer this target at startup. + credentialRequestedForTarget.delete(args.targetId) } }) } diff --git a/src/main/ipc/ssh-ipc-module-mocks.ts b/src/main/ipc/ssh-ipc-module-mocks.ts index af05f0b12049..0855a739fb2b 100644 --- a/src/main/ipc/ssh-ipc-module-mocks.ts +++ b/src/main/ipc/ssh-ipc-module-mocks.ts @@ -144,7 +144,6 @@ export function createSshIpcMocks(): SshIpcMocks { installSshPtySourceCancellationPublisher: vi.fn().mockReturnValue(() => {}) }, sshConnectionStore: { - isRuntimeOwnedSshTarget: (target: { owner?: unknown }) => target.owner !== undefined, SshConnectionStore: class MockSshConnectionStore { constructor() { return mockSshStore diff --git a/src/main/ipc/ssh-renderer-broadcast.ts b/src/main/ipc/ssh-renderer-broadcast.ts index 8dbc1cfede16..999b8e9a3462 100644 --- a/src/main/ipc/ssh-renderer-broadcast.ts +++ b/src/main/ipc/ssh-renderer-broadcast.ts @@ -12,10 +12,8 @@ import { enrichSshForwardEntries, getWorktreeIdsForConnection } from '../ports/ssh-advertised-url-enrichment' -import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store' import { getSshProviderAuthority } from '../ssh/ssh-provider-authority' import { getSshPlainSshMode } from '../ssh/ssh-plain-ssh-mode' -import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { activeSessions } from './ssh-active-relay-sessions' import { connectionManager, @@ -32,9 +30,8 @@ export function broadcastSshState( targetId: string, state: SshConnectionState ): void { - const target = getSshTargetRegistryStore()?.getTarget(targetId) - // Why: owned targets are hidden from clients; broadcasting them leaks internal transport state into persisted reconnect hints. - if (isRuntimeOwnedSshTargetId(targetId) || (target && isRuntimeOwnedSshTarget(target))) { + // Why: runtime-owned (ephemeral-VM) targets are hidden from the renderer, so broadcasting their state only triggers wasted listTargets() lookups. + if (isRuntimeOwnedSshTargetId(targetId)) { currentRuntime?.invalidateSshWorktreeScanCache?.(targetId) return } diff --git a/src/main/ipc/ssh-shutdown-drain.ts b/src/main/ipc/ssh-shutdown-drain.ts index 93a25071bfc6..f66db066c011 100644 --- a/src/main/ipc/ssh-shutdown-drain.ts +++ b/src/main/ipc/ssh-shutdown-drain.ts @@ -61,34 +61,22 @@ async function settleTasksWithinMs( return { timedOut: [...pending], errors } } -/** Which targets the quit drain may detach; a target it may not keeps its session and transport. */ -export type SshShutdownDetachPredicate = (targetId: string) => boolean - -const detachEveryTarget: SshShutdownDetachPredicate = () => true - -function sshShutdownTasks( - targetIds: readonly string[], - mayDetach: SshShutdownDetachPredicate -): SshShutdownTask[] { +function sshShutdownTasks(targetIds: readonly string[]): SshShutdownTask[] { return [ ...targetIds - .filter((targetId) => activeSessions.has(targetId) && mayDetach(targetId)) + .filter((targetId) => activeSessions.has(targetId)) .map((targetId) => ({ targetId, promise: teardownActiveSshSession(targetId, (session) => session.detachAndPersist()) })), - { - targetId: '*transports', - promise: connectionManager?.disconnectAll(mayDetach) ?? Promise.resolve() - } + { targetId: '*transports', promise: connectionManager?.disconnectAll() ?? Promise.resolve() } ] } async function drainSshShutdown( targetIds: readonly string[], inFlight: readonly SshShutdownTask[], - detachErrors: readonly unknown[] = [], - mayDetach: SshShutdownDetachPredicate = detachEveryTarget + detachErrors: readonly unknown[] = [] ): Promise { const deadline = Date.now() + SSH_SHUTDOWN_BUDGET_MS const unfinished: SshShutdownUnfinished[] = [] @@ -108,12 +96,12 @@ async function drainSshShutdown( return settled.timedOut.length === 0 } - await runPhase('drain', sshShutdownTasks(targetIds, mayDetach)) + await runPhase('drain', sshShutdownTasks(targetIds)) // Why a second drain after the join: a connect paused in old-session teardown still publishes its // replacement session and opens a transport before it reaches the cancellation checkpoint, so the // first drain can miss both. if (await runPhase('in-flight-join', inFlight)) { - await runPhase('final-drain', sshShutdownTasks([...activeSessions.keys()], mayDetach)) + await runPhase('final-drain', sshShutdownTasks([...activeSessions.keys()])) } if (errors.length > 0 || unfinished.length > 0) { @@ -135,9 +123,7 @@ async function drainSshShutdown( // // Why no fence latch here: the committed quit path sets it before calling this, so it is already on // for the snapshot below. Called without that gate (tests), this degrades to a plain drain. -export function beginSshShutdown( - mayDetach: SshShutdownDetachPredicate = detachEveryTarget -): Promise { +export function beginSshShutdown(): Promise { if (sshShutdownDrain) { return sshShutdownDrain } @@ -150,18 +136,13 @@ export function beginSshShutdown( ...[...testConnectionProbes].map((promise) => ({ targetId: '*probe', promise })) ] for (const targetId of Array.from(connectInFlight.keys())) { - if (mayDetach(targetId)) { - invalidateConnectAttempt(targetId) - } + invalidateConnectAttempt(targetId) } const targetIds = [...activeSessions.keys()] // Why before any await: this is the whole point of the split. Each session marks its recovery lease // detached in memory now, and the final flush persists it — the remote PTYs keep running. const detachErrors: unknown[] = [] - for (const [targetId, session] of activeSessions) { - if (!mayDetach(targetId)) { - continue - } + for (const session of activeSessions.values()) { // Why per-session: this runs synchronously inside a non-async will-quit listener, so one throw // (teardownProviders -> webContents.send on a destroyed renderer, routine on quit) would escape // it and skip every later session, the drain assignment, and the store flush that persists all @@ -172,6 +153,6 @@ export function beginSshShutdown( detachErrors.push(error) } } - sshShutdownDrain = drainSshShutdown(targetIds, inFlight, detachErrors, mayDetach) + sshShutdownDrain = drainSshShutdown(targetIds, inFlight, detachErrors) return sshShutdownDrain } diff --git a/src/main/ipc/ssh-state-broadcast-fanout.test.ts b/src/main/ipc/ssh-state-broadcast-fanout.test.ts index 9c9ae661237d..e7f6480c261c 100644 --- a/src/main/ipc/ssh-state-broadcast-fanout.test.ts +++ b/src/main/ipc/ssh-state-broadcast-fanout.test.ts @@ -175,41 +175,6 @@ describe('SSH IPC handlers', () => { expect(runtime.notifySshStateChanged).not.toHaveBeenCalled() }) - it('keeps claimed managed-orcad SSH state off clients under the original target id', () => { - const runtime = { - onPtyData: vi.fn(), - onPtyExit: vi.fn(), - invalidateSshWorktreeScanCache: vi.fn(), - notifySshStateChanged: vi.fn() - } - registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never) - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Managed orcad host', - host: 'example.com', - port: 22, - username: 'deploy', - owner: { type: 'on-demand-runtime', runtimeId: 'managed-orcad:environment-1' } - } satisfies SshTarget) - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - expect(runtime.invalidateSshWorktreeScanCache).toHaveBeenCalledWith('ssh-1') - expect(runtime.notifySshStateChanged).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith( - 'ssh:state-changed', - expect.anything() - ) - }) - it('invalidates runtime scans from hidden SSH state broadcasts', () => { const runtime = { onPtyData: vi.fn(), diff --git a/src/main/ipc/ssh-target-lifecycle-queue.ts b/src/main/ipc/ssh-target-lifecycle-queue.ts index f6c5221aa5c0..014a5037175d 100644 --- a/src/main/ipc/ssh-target-lifecycle-queue.ts +++ b/src/main/ipc/ssh-target-lifecycle-queue.ts @@ -1,27 +1,25 @@ // Serializes disconnect/remove/terminate/reset for a single SSH target so they cannot interleave. export const targetLifecycleInFlight = new Map>() -export function runTargetLifecycle(targetId: string, operation: () => Promise): Promise { +export function runTargetLifecycle( + targetId: string, + operation: () => Promise +): Promise { const prior = targetLifecycleInFlight.get(targetId) const operationPromise = (async () => { if (prior) { await prior.catch(() => undefined) } - return operation() + await operation() })() let trackedPromise!: Promise - trackedPromise = operationPromise - .then( - () => undefined, - () => undefined - ) - .finally(() => { - if (targetLifecycleInFlight.get(targetId) === trackedPromise) { - targetLifecycleInFlight.delete(targetId) - } - }) + trackedPromise = operationPromise.finally(() => { + if (targetLifecycleInFlight.get(targetId) === trackedPromise) { + targetLifecycleInFlight.delete(targetId) + } + }) targetLifecycleInFlight.set(targetId, trackedPromise) - return operationPromise + return trackedPromise } export async function awaitTargetLifecycle(targetId: string): Promise { diff --git a/src/main/ipc/ssh-test-connection-probe-registry.test.ts b/src/main/ipc/ssh-test-connection-probe-registry.test.ts deleted file mode 100644 index c8b75e1c1ea0..000000000000 --- a/src/main/ipc/ssh-test-connection-probe-registry.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { - awaitSshTestConnectionProbes, - credentialRequestedForTarget, - runSshTestConnectionProbe, - testConnectionProbes, - testingTargets -} from './ssh-connect-attempt-registry' - -function deferred() { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -it('publishes global and target tracking before callback-capable work', async () => { - const close = deferred() - let joined!: Promise - const finished = vi.fn() - const operation = vi.fn(async () => { - expect(testConnectionProbes.has(probe)).toBe(true) - expect(testingTargets.has('published')).toBe(true) - joined = awaitSshTestConnectionProbes('published').then(finished) - await close.promise - return 'connected' - }) - const probe = runSshTestConnectionProbe('published', operation) - expect(operation).not.toHaveBeenCalled() - expect(testConnectionProbes.has(probe)).toBe(true) - await Promise.resolve() - expect(finished).not.toHaveBeenCalled() - close.resolve() - await expect(probe).resolves.toBe('connected') - await joined - expect(finished).toHaveBeenCalledTimes(1) - expect(testConnectionProbes.has(probe)).toBe(false) - expect(testingTargets.has('published')).toBe(false) -}) - -it('joins every admitted probe and retains callback suppression until the final settlement', async () => { - const first = deferred() - const second = deferred() - const a = runSshTestConnectionProbe('multiple', () => first.promise) - const b = runSshTestConnectionProbe('multiple', () => second.promise) - credentialRequestedForTarget.add('multiple') - const finished = vi.fn() - const joined = awaitSshTestConnectionProbes('multiple').then(finished) - first.resolve() - await a - expect(testingTargets.has('multiple')).toBe(true) - expect(credentialRequestedForTarget.has('multiple')).toBe(true) - expect(finished).not.toHaveBeenCalled() - second.resolve() - await b - await joined - expect(testingTargets.has('multiple')).toBe(false) - expect(credentialRequestedForTarget.has('multiple')).toBe(false) -}) - -it('joins failed probes without mistaking rejection for unfinished work', async () => { - const failure = new Error('connection failed') - const probe = runSshTestConnectionProbe('failed', async () => { - throw failure - }) - const result = expect(probe).rejects.toBe(failure) - await expect(awaitSshTestConnectionProbes('failed')).resolves.toBeUndefined() - await result - expect(testConnectionProbes.has(probe)).toBe(false) - expect(testingTargets.has('failed')).toBe(false) -}) - -it('does not wait for another target', async () => { - const close = deferred() - const probe = runSshTestConnectionProbe('other', () => close.promise) - await awaitSshTestConnectionProbes('absent') - expect(testConnectionProbes.has(probe)).toBe(true) - close.resolve() - await probe -}) - -it('rechecks the target after a joined snapshot settles', async () => { - const first = deferred() - const second = deferred() - const a = runSshTestConnectionProbe('later', () => first.promise) - const finished = vi.fn() - const joined = awaitSshTestConnectionProbes('later').then(finished) - const b = runSshTestConnectionProbe('later', () => second.promise) - first.resolve() - await a - await Promise.resolve() - expect(finished).not.toHaveBeenCalled() - second.resolve() - await b - await joined - expect(finished).toHaveBeenCalledTimes(1) -}) diff --git a/src/main/ipc/ssh-test-probe-presence.test.ts b/src/main/ipc/ssh-test-probe-presence.test.ts deleted file mode 100644 index 73287c4798f0..000000000000 --- a/src/main/ipc/ssh-test-probe-presence.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { expect, it } from 'vitest' -import { - hasSshTestConnectionProbes, - runSshTestConnectionProbe, - testingTargets -} from './ssh-connect-attempt-registry' - -it('publishes target-scoped presence before callbacks and retains it until settlement', async () => { - const target = 'probe-presence-success' - const work = Promise.withResolvers() - const probe = runSshTestConnectionProbe(target, async () => { - expect(hasSshTestConnectionProbes(target)).toBe(true) - await work.promise - }) - expect(hasSshTestConnectionProbes(target)).toBe(true) - expect(hasSshTestConnectionProbes('unrelated')).toBe(false) - testingTargets.delete(target) - expect(hasSshTestConnectionProbes(target)).toBe(true) - work.resolve() - await probe - expect(hasSshTestConnectionProbes(target)).toBe(false) -}) - -it('keeps other probes visible after a failed probe settles', async () => { - const target = 'probe-presence-failure' - const work = Promise.withResolvers() - const pending = runSshTestConnectionProbe(target, () => work.promise) - const failed = runSshTestConnectionProbe(target, async () => { - throw new Error('failed') - }) - await expect(failed).rejects.toThrow('failed') - expect(hasSshTestConnectionProbes(target)).toBe(true) - work.resolve() - await pending - expect(hasSshTestConnectionProbes(target)).toBe(false) -}) diff --git a/src/main/ipc/worktrees-ssh-provider-authority.test.ts b/src/main/ipc/worktrees-ssh-provider-authority.test.ts index 8eb025da52f6..8d14755ab18d 100644 --- a/src/main/ipc/worktrees-ssh-provider-authority.test.ts +++ b/src/main/ipc/worktrees-ssh-provider-authority.test.ts @@ -5,7 +5,6 @@ import type { ProviderRequestId } from '../../shared/detected-worktree-provider- import { toSshExecutionHostId } from '../../shared/execution-host' import { DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS } from './worktrees/listing/register-detected-worktree-handlers' import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { hasSshProviderContinuations } from '../ssh/ssh-provider-continuations' import { getSshGitProviderMock } from './worktrees-test-module-mocks' import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness' @@ -96,99 +95,6 @@ describe('registerWorktreeHandlers', () => { setupWorktreeHandlers() }) - it.each(['resolve', 'reject'] as const)( - 'retains canceled SSH provider continuations until the provider settles: %s', - async (outcome) => { - const targetId = `continuation-cancel-${outcome}` - const repo = { - id: `repo-${targetId}`, - path: '/remote/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - connectionId: targetId - } - let resolveProvider!: (value: GitWorktreeInfo[]) => void - let rejectProvider!: (error: Error) => void - const provider = { - listWorktrees: vi.fn( - () => - new Promise((resolve, reject) => { - resolveProvider = resolve - rejectProvider = reject - }) - ) - } - store.getRepos.mockReturnValue([repo]) - getSshGitProviderMock.mockReturnValue(provider) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: request ids are minted by the renderer; any unique string is a valid id here. - const providerRequestId = `request-${targetId}` as ProviderRequestId - const pending = handlers['worktrees:listDetected'](ipcEvent, { - providerRequestId, - repoId: repo.id, - executionHostId: toSshExecutionHostId(targetId), - expectedAuthority: getSshProviderAuthority(targetId) - }) - await Promise.resolve() - expect(provider.listWorktrees).toHaveBeenCalledOnce() - expect(hasSshProviderContinuations(targetId)).toBe(true) - handlers['worktrees:cancelListDetected'](ipcEvent, { providerRequestId }) - await expect(pending).resolves.toMatchObject({ status: 'canceled' }) - expect(hasSshProviderContinuations(targetId)).toBe(true) - expect(hasSshProviderContinuations(`${targetId}-other`)).toBe(false) - if (outcome === 'resolve') { - resolveProvider([]) - } else { - rejectProvider(new Error('late provider failure')) - } - await vi.waitFor(() => expect(hasSshProviderContinuations(targetId)).toBe(false)) - expect(store.setWorktreeMeta).not.toHaveBeenCalled() - expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - } - ) - - it('tracks legacy SSH listing until settlement without tracking a local folder listing', async () => { - const targetId = 'continuation-legacy' - const repo = { - id: 'repo-legacy', - path: '/remote/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - connectionId: targetId - } - const localRepo: Repo = { - id: 'repo-local', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0, - kind: 'folder' - } - let resolveProvider!: (value: GitWorktreeInfo[]) => void - const provider = { - listWorktrees: vi.fn( - () => - new Promise((resolve) => { - resolveProvider = resolve - }) - ) - } - store.getRepos.mockReturnValue([repo, localRepo]) - getSshGitProviderMock.mockReturnValue(provider) - const pending = handlers['worktrees:listDetected'](ipcEvent, { repoId: repo.id }) - expect(hasSshProviderContinuations(targetId)).toBe(true) - await expect( - handlers['worktrees:listDetected'](ipcEvent, { repoId: localRepo.id }) - ).resolves.toMatchObject({ repoId: localRepo.id, authoritative: true }) - expect(hasSshProviderContinuations(localRepo.id)).toBe(false) - expect(hasSshProviderContinuations(targetId)).toBe(true) - expect(provider.listWorktrees).toHaveBeenCalledOnce() - resolveProvider([]) - await expect(pending).resolves.toMatchObject({ repoId: repo.id, authoritative: true }) - expect(hasSshProviderContinuations(targetId)).toBe(false) - }) - it.each([ ['malformed', 'ssh:%'], ['contradictory', toSshExecutionHostId('target-b')] diff --git a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts index de466ff519e3..442a70985c8d 100644 --- a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts @@ -1,5 +1,4 @@ import { ipcMain } from 'electron' -import { runSshProviderContinuation } from '../../../ssh/ssh-provider-continuations' import type { DetectedWorktreeListResult } from '../../../../shared/worktree/types' import type { HostQualifiedDetectedWorktreeResult, @@ -73,21 +72,16 @@ export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): v }, DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS) : undefined try { - const list = () => - listHostQualifiedDetectedWorktrees( - store, - args, - controller - ? { - signal: controller.signal, - status: () => (timedOut ? 'timed-out' : 'canceled') - } - : undefined - ) - const providerResult = - parsedHost?.kind === 'ssh' - ? runSshProviderContinuation(parsedHost.targetId, list) - : list() + const providerResult = listHostQualifiedDetectedWorktrees( + store, + args, + controller + ? { + signal: controller.signal, + status: () => (timedOut ? 'timed-out' : 'canceled') + } + : undefined + ) return abortedResult ? await Promise.race([providerResult, abortedResult]) : await providerResult @@ -112,19 +106,17 @@ export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): v const connectionId = getSshTargetIdForExecutionHost(getRepoExecutionHostId(repo)) const provider = connectionId ? getSshGitProvider(connectionId) : undefined const authority = connectionId ? { ...getSshProviderAuthority(connectionId) } : undefined - const list = () => - listDetectedWorktreesForCapturedRepo( - store, - repo, - () => - isCapturedRepoCurrent(store, repo) && - (!connectionId || - (getSshGitProvider(connectionId) === provider && - authority !== undefined && - isCurrentSshProviderAuthority(authority))), - provider - ) - const result = await (connectionId ? runSshProviderContinuation(connectionId, list) : list()) + const result = await listDetectedWorktreesForCapturedRepo( + store, + repo, + () => + isCapturedRepoCurrent(store, repo) && + (!connectionId || + (getSshGitProvider(connectionId) === provider && + authority !== undefined && + isCurrentSshProviderAuthority(authority))), + provider + ) return result && !('providerAbortStatus' in result) ? result : { diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts index d5e09d2f8f28..d91743b01850 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -1,10 +1,7 @@ import { ipcMain } from 'electron' import { getLocalWorktreeCatalogVersion } from '../../../local-worktree-scan-generation' import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' -import { - getRepoExecutionHostId, - getSshTargetIdForExecutionHost -} from '../../../../shared/execution-host' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { withWorktreeSpan } from '../../../observability/instrumentation' import { parseWorktreeId } from '../../worktree-logic' import type { RemoveWorktreeArgs } from '../ipc-context-schemas' @@ -20,7 +17,6 @@ import { waitForPendingWorktreeRemoval } from '../../../worktree-background-removal' import { runSerializedWorktreeRemovalAcceptance } from '../../../worktree-removal-acceptance-queue' -import { runSshProviderContinuation } from '../../../ssh/ssh-provider-continuations' export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): void { const { store, options, worktreeRemovalsInFlight } = context @@ -40,7 +36,6 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo if (pending) { return { ...(await pending), catalogVersion: getLocalWorktreeCatalogVersion(repoId) } } - const targetId = getSshTargetIdForExecutionHost(removalHostId) ?? repo.connectionId const inFlightKey = getWorktreeRemovalInFlightKey(args.worktreeId, removalHostId) const optionsKey = getWorktreeRemovalOptionsKey(args) const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey) @@ -53,15 +48,11 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo // Why: concurrent stale-toast/double-click/sidebar races can hit the same worktree; share the op so only one path touches Git and disk. const removal = withWorktreeSpan({ stage: 'remove', path: worktreePath }, async () => { - const execute = () => - executeWorktreeRemoval(context, args, repo, repoId, worktreePath, removalHostId) const accept = async (): Promise => // Why: another client's removal of this worktree may have been accepted during the wait. waitForPendingWorktreeRemoval(args.worktreeId, removalHostId) ? { removing: true } - : targetId - ? runSshProviderContinuation(targetId, execute) - : execute() + : executeWorktreeRemoval(context, args, repo, repoId, worktreePath, removalHostId) const accepted = await (repo.connectionId ? accept() : runSerializedWorktreeRemovalAcceptance(repo.path, accept)) diff --git a/src/main/ipc/worktrees/removal/worktree-removal-continuations.test.ts b/src/main/ipc/worktrees/removal/worktree-removal-continuations.test.ts deleted file mode 100644 index a57bd68d594f..000000000000 --- a/src/main/ipc/worktrees/removal/worktree-removal-continuations.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' -import { hasSshProviderContinuations } from '../../../ssh/ssh-provider-continuations' -import type { WorktreeIpcContext } from '../worktree-ipc-context' -import { registerWorktreeRemovalHandlers } from './register-worktree-removal-handlers' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - execute: vi.fn(), - resolveRepo: vi.fn() -})) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../../../observability/instrumentation', () => ({ - withWorktreeSpan: (_options: unknown, operation: () => unknown) => operation() -})) -vi.mock('../../worktree-logic', () => ({ - parseWorktreeId: () => ({ repoId: 'repo', worktreePath: '/work/feature' }) -})) -vi.mock('../repo-host-ownership', () => ({ resolveRepoForExecutionHost: mocks.resolveRepo })) -vi.mock('./execute-worktree-removal', () => ({ executeWorktreeRemoval: mocks.execute })) - -beforeEach(() => vi.clearAllMocks()) - -function setup(connectionId?: string, kind = 'git') { - mocks.resolveRepo.mockReturnValue({ id: 'repo', connectionId, kind }) - const worktreeRemovalsInFlight = new Map() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: removal reads only store and worktreeRemovalsInFlight from the context. - registerWorktreeRemovalHandlers({ - store: {}, - worktreeRemovalsInFlight - } as unknown as WorktreeIpcContext) - const handler = mocks.handle.mock.calls[0]![1] - return { - remove: (): Promise => handler({}, { worktreeId: 'repo:/work/feature' }), - worktreeRemovalsInFlight - } -} - -it.each(['git', 'folder'])( - 'tracks the full SSH %s removal before invocation and through cleanup', - async (kind) => { - const state = setup('target', kind) - const removed = Promise.withResolvers() - const cleaned = Promise.withResolvers() - mocks.execute.mockImplementation(async () => { - expect(hasSshProviderContinuations('target')).toBe(true) - await removed.promise - await cleaned.promise - return { success: true } - }) - const first = state.remove() - const joined = state.remove() - expect(mocks.execute).toHaveBeenCalledOnce() - expect(hasSshProviderContinuations('elsewhere')).toBe(false) - removed.resolve() - await Promise.resolve() - expect(hasSshProviderContinuations('target')).toBe(true) - cleaned.resolve() - await Promise.all([first, joined]) - expect(hasSshProviderContinuations('target')).toBe(false) - expect(state.worktreeRemovalsInFlight.size).toBe(0) - } -) - -it('retains a failed removal until its underlying operation rejects', async () => { - const state = setup('target') - const pending = Promise.withResolvers() - mocks.execute.mockReturnValue(pending.promise) - const result = state.remove() - expect(hasSshProviderContinuations('target')).toBe(true) - pending.reject(new Error('cleanup failed')) - await expect(result).rejects.toThrow('cleanup failed') - expect(hasSshProviderContinuations('target')).toBe(false) - expect(state.worktreeRemovalsInFlight.size).toBe(0) -}) - -it('does not register local removal as SSH work', async () => { - const state = setup() - const pending = Promise.withResolvers() - mocks.execute.mockReturnValue(pending.promise) - const result = state.remove() - await vi.waitFor(() => expect(mocks.execute).toHaveBeenCalledOnce()) - expect(hasSshProviderContinuations('target')).toBe(false) - pending.resolve({}) - await result -}) - -it('tracks a folder owner expressed only by its unified execution host', async () => { - const state = setup() - mocks.resolveRepo.mockReturnValue({ id: 'repo', kind: 'folder', executionHostId: 'ssh:target' }) - const pending = Promise.withResolvers() - mocks.execute.mockReturnValue(pending.promise) - const result = state.remove() - // Local acceptance is serialized per repo path first, so the continuation starts after a turn. - await vi.waitFor(() => expect(hasSshProviderContinuations('target')).toBe(true)) - pending.resolve({}) - await result - expect(hasSshProviderContinuations('target')).toBe(false) -}) diff --git a/src/main/providers/ssh-filesystem-dispatch.ts b/src/main/providers/ssh-filesystem-dispatch.ts index b7171f75dc05..04ef209bef15 100644 --- a/src/main/providers/ssh-filesystem-dispatch.ts +++ b/src/main/providers/ssh-filesystem-dispatch.ts @@ -37,16 +37,6 @@ export function unregisterSshFilesystemProvider(connectionId: string): void { sshProviders.delete(connectionId) } -export function unregisterSshFilesystemProviderIfCurrent( - connectionId: string, - expected: IFilesystemProvider -): boolean { - if (sshProviders.get(connectionId) !== expected) { - return false - } - return sshProviders.delete(connectionId) -} - export function getSshFilesystemProvider(connectionId: string): IFilesystemProvider | undefined { return sshProviders.get(connectionId) } diff --git a/src/main/providers/ssh-git-dispatch.ts b/src/main/providers/ssh-git-dispatch.ts index 73f120bcb136..2a73d57f9c76 100644 --- a/src/main/providers/ssh-git-dispatch.ts +++ b/src/main/providers/ssh-git-dispatch.ts @@ -49,21 +49,6 @@ export function _getSshGitProviderGenerationCacheSize(): number { return sshProviderGenerations.size } -export function unregisterSshGitProviderIfCurrent( - connectionId: string, - expected: SshGitProvider, - expectedGeneration: number -): boolean { - if ( - sshProviders.get(connectionId) !== expected || - getSshGitProviderGeneration(connectionId) !== expectedGeneration - ) { - return false - } - unregisterSshGitProvider(connectionId) - return true -} - export function getSshGitProvider(connectionId: string): SshGitProvider | undefined { return sshProviders.get(connectionId) } diff --git a/src/main/ssh/ssh-connection-disconnect-drain.test.ts b/src/main/ssh/ssh-connection-disconnect-drain.test.ts index 4b04eae0e9a4..b5244ff5f327 100644 --- a/src/main/ssh/ssh-connection-disconnect-drain.test.ts +++ b/src/main/ssh/ssh-connection-disconnect-drain.test.ts @@ -1,8 +1,6 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { SshConnection } from './ssh-connection' -import { SshConnectionManager } from './ssh-connection-manager' -import { assertManagerTargetTransportsClosed } from './ssh-connection-manager-test-probes' import { createCallbacks, createResolvedConfig, createTarget } from './ssh-connection-test-fixtures' import { clientInstances, resetSshConnectionMocks, ssh2Mock } from './ssh-connection-test-harness' import { resolveWithSshG } from './ssh-config-parser' @@ -235,30 +233,6 @@ it('refuses a disconnected connection without claiming drain proof', async () => await expect(conn.disconnectAndDrain(new AbortController().signal)).rejects.toThrow() }) -it('retains ordinary manager transport debt after pool removal until physical close', async () => { - const manager = new SshConnectionManager(createCallbacks()) - const target = createTarget() - const conn = await manager.connect(target) - const client = conn.getClient()! - await manager.disconnect(target.id) - expect(manager.getConnection(target.id)).toBeUndefined() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow('closure_unproven') - client.emit('close') - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() -}) - -it('cleans failed ordinary startup without mistaking destroy for physical close', async () => { - const manager = new SshConnectionManager(createCallbacks()) - const target = createTarget() - ssh2Mock.connectSequence = [new Error('startup refused')] - ssh2Mock.destroyErrorMessage = 'socket still closing' - await expect(manager.connect(target)).rejects.toThrow('startup refused') - expect(manager.getConnection(target.id)).toBeUndefined() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow('closure_unproven') - clientInstances[0]!.emit('close') - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() -}) - it('closes owned system SSH but refuses unproven drain', async () => { vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) diff --git a/src/main/ssh/ssh-connection-manager-closure.test.ts b/src/main/ssh/ssh-connection-manager-closure.test.ts deleted file mode 100644 index 79e456b16533..000000000000 --- a/src/main/ssh/ssh-connection-manager-closure.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { - assertManagerTargetTransportsClosed, - managerTargetActive, - managerUnclosedTransportTargets -} from './ssh-connection-manager-test-probes' -import { beforeEach, expect, it, vi } from 'vitest' -import type { SshTarget } from '../../shared/ssh-types' - -type ClosureMockState = { - clients: { close: () => void; status: string }[] - connectError?: Error - disconnectError?: Error -} - -const state = vi.hoisted((): ClosureMockState => ({ clients: [] })) -vi.mock('./ssh-connection', () => ({ - SshConnection: class { - status = 'connected' - close = () => {} - constructor() { - state.clients.push(this) - } - subscribeTransportClosure(callback: () => void) { - this.close = callback - return () => {} - } - async connect() { - if (state.connectError) { - throw state.connectError - } - } - async disconnect() { - if (state.disconnectError) { - throw state.disconnectError - } - this.status = 'disconnected' - } - async disconnectAndDrain() { - await this.disconnect() - this.close() - } - getState() { - return { status: this.status } - } - setCallbacks() {} - } -})) -import { SshConnectionManager } from './ssh-connection-manager' - -const target: SshTarget = { - id: 'owned', - label: 'Owned', - host: 'example.test', - port: 22, - username: 'deploy' -} -beforeEach(() => { - state.clients.length = 0 - state.connectError = undefined - state.disconnectError = undefined -}) - -it('retains failed startup transport evidence until its disposed connection physically closes', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - state.connectError = new Error('authentication rejected') - await expect(manager.connect(target)).rejects.toThrow('authentication rejected') - expect(state.clients[0].status).toBe('disconnected') - expect(manager.getConnection(target.id)).toBeUndefined() - expect(managerTargetActive(manager, target.id)).toBe(false) - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow('closure_unproven') - state.clients[0].close() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() -}) - -it('requires every allocation and ignores duplicate close notifications', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - await manager.connect(target) - await manager.disconnect(target.id) - await manager.connect(target) - await manager.disconnect(target.id) - state.clients[0].close() - state.clients[0].close() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow('closure_unproven') - state.clients[1].close() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() -}) - -it('does not let an old close remove a replacement or taint an unrelated target', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - await manager.connect(target) - await manager.disconnect(target.id) - const replacement = await manager.connect(target) - state.clients[0].close() - expect(manager.getConnection(target.id)).toBe(replacement) - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow() - expect(() => assertManagerTargetTransportsClosed(manager, 'unrelated')).not.toThrow() -}) - -it('releases bookkeeping after repeated observed closure', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - for (let index = 0; index < 100; index++) { - await manager.connect(target) - await manager.disconnect(target.id) - state.clients[index].close() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() - } - expect(managerUnclosedTransportTargets(manager)).toBe(0) -}) diff --git a/src/main/ssh/ssh-connection-manager-registry.test.ts b/src/main/ssh/ssh-connection-manager-registry.test.ts index a7b2b06e8059..e6a6bc24843d 100644 --- a/src/main/ssh/ssh-connection-manager-registry.test.ts +++ b/src/main/ssh/ssh-connection-manager-registry.test.ts @@ -88,57 +88,4 @@ describe('SshConnectionManager', () => { expect(mgr.getConnection('a')).toBeUndefined() expect(mgr.getConnection('b')).toBeUndefined() }) - - it('disconnectAll preserves excluded reset connections in the registry', async () => { - const mgr = new SshConnectionManager(createCallbacks()) - const retained = await mgr.connect(createTarget({ id: 'reset' })) - await mgr.connect(createTarget({ id: 'ordinary' })) - const disconnect = vi.spyOn(retained, 'disconnect') - await mgr.disconnectAll((id) => id !== 'reset') - expect(disconnect).not.toHaveBeenCalled() - expect(mgr.getConnection('reset')).toBe(retained) - expect(mgr.getConnection('ordinary')).toBeUndefined() - await mgr.disconnectAll() - }) - - it('checks disconnect admission immediately before each transport effect', async () => { - const mgr = new SshConnectionManager(createCallbacks()) - const first = await mgr.connect(createTarget({ id: 'first' })) - const second = await mgr.connect(createTarget({ id: 'second' })) - const disconnectSecond = vi.spyOn(second, 'disconnect') - let reserved = false - const originalDisconnect = first.disconnect.bind(first) - vi.spyOn(first, 'disconnect').mockImplementation(() => { - reserved = true - return originalDisconnect() - }) - await mgr.disconnectAll((id) => id !== 'second' || !reserved) - expect(disconnectSecond).not.toHaveBeenCalled() - expect(mgr.getConnection('second')).toBe(second) - await mgr.disconnectAll() - }) - - it('does not erase a replacement registered while an admitted disconnect settles', async () => { - const mgr = new SshConnectionManager(createCallbacks()) - const target = createTarget({ id: 'target' }) - const old = await mgr.connect(target) - const state = old.getState() - vi.spyOn(old, 'getState').mockReturnValue({ ...state, status: 'disconnected' }) - let finish!: () => void - vi.spyOn(old, 'disconnect') - .mockImplementationOnce( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - .mockResolvedValue(undefined) - const drain = mgr.disconnectAll() - const replacement = await mgr.connect(target) - expect(replacement).not.toBe(old) - finish() - await drain - expect(mgr.getConnection('target')).toBe(replacement) - await mgr.disconnectAll() - }) }) diff --git a/src/main/ssh/ssh-connection-manager-test-probes.ts b/src/main/ssh/ssh-connection-manager-test-probes.ts deleted file mode 100644 index 3c85210d5968..000000000000 --- a/src/main/ssh/ssh-connection-manager-test-probes.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { SshConnectionManager } from './ssh-connection-manager' - -type ManagerBookkeeping = { - connections: Map - connectingTargets: Map - pendingTargetOperations: Map - unconfirmedTargetTeardowns: Set - unclosedTransportsByTarget: Map -} - -function bookkeeping(manager: SshConnectionManager): ManagerBookkeeping { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are SshConnectionManager's own private fields; tests read them until T3/T8 expose readers. - return manager as unknown as ManagerBookkeeping -} - -/** Test-side reading of the target activity T3's hasTargetActivity will expose. */ -export function managerTargetActive(manager: SshConnectionManager, targetId: string): boolean { - const state = bookkeeping(manager) - return ( - state.connections.has(targetId) || - state.connectingTargets.has(targetId) || - state.unconfirmedTargetTeardowns.has(targetId) || - state.pendingTargetOperations.has(targetId) - ) -} - -/** Test-side reading of the closure proof T8's assertTargetTransportsClosed will expose. */ -export function assertManagerTargetTransportsClosed( - manager: SshConnectionManager, - targetId: string -): void { - if ( - managerTargetActive(manager, targetId) || - bookkeeping(manager).unclosedTransportsByTarget.has(targetId) - ) { - throw new Error('ssh_target_transport_closure_unproven') - } -} - -/** How many targets still owe a physical transport close. */ -export function managerUnclosedTransportTargets(manager: SshConnectionManager): number { - return bookkeeping(manager).unclosedTransportsByTarget.size -} diff --git a/src/main/ssh/ssh-connection-manager.test.ts b/src/main/ssh/ssh-connection-manager.test.ts index b5c61574feca..b2de497a006d 100644 --- a/src/main/ssh/ssh-connection-manager.test.ts +++ b/src/main/ssh/ssh-connection-manager.test.ts @@ -1,4 +1,3 @@ -import { managerTargetActive } from './ssh-connection-manager-test-probes' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SshTarget } from '../../shared/ssh-types' @@ -13,7 +12,6 @@ const mockState = vi.hoisted(() => ({ vi.mock('./ssh-connection', () => ({ SshConnection: class MockSshConnection { - subscribeTransportClosure = vi.fn(() => () => {}) status: 'connecting' | 'connected' | 'disconnected' = 'connecting' connect = vi.fn(async () => { await (mockState.connectResults.shift() ?? Promise.resolve()) @@ -22,7 +20,6 @@ vi.mock('./ssh-connection', () => ({ disconnect = vi.fn(async () => { this.status = 'disconnected' }) - reconnect = vi.fn(async () => {}) constructor() { mockState.instances.push(this) @@ -48,53 +45,6 @@ const target = { } as SshTarget describe('SshConnectionManager', () => { - it('reports invalidated pending attempts even after disconnect removes the registration', async () => { - let reject!: (error: Error) => void - mockState.connectResults.push( - new Promise((_resolve, fail) => { - reject = fail - }) - ) - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - const pending = manager.connect(target) - const rejected = expect(pending).rejects.toThrow('cancelled') - await manager.disconnect(target.id) - expect(manager.getConnection(target.id)).toBeUndefined() - expect(managerTargetActive(manager, target.id)).toBe(true) - expect(managerTargetActive(manager, 'unrelated')).toBe(false) - reject(new Error('cancelled')) - await rejected - expect(managerTargetActive(manager, target.id)).toBe(false) - }) - - it('tracks detached exact-connection teardown until its promise settles', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - const conn = await manager.connect(target) - await manager.disconnect(target.id) - let finish!: () => void - mockState.instances[0].disconnect.mockImplementationOnce( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - const pending = manager.disconnectConnection(target.id, conn) - expect(managerTargetActive(manager, target.id)).toBe(true) - finish() - await pending - expect(managerTargetActive(manager, target.id)).toBe(false) - }) - - it('retains uncertainty after bulk teardown fails and removes the registration', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - await manager.connect(target) - mockState.instances[0].disconnect.mockRejectedValueOnce(new Error('close unconfirmed')) - await manager.disconnectAll() - expect(manager.getConnection(target.id)).toBeUndefined() - expect(managerTargetActive(manager, target.id)).toBe(true) - expect(managerTargetActive(manager, 'unrelated')).toBe(false) - }) - beforeEach(() => { mockState.connectResults.length = 0 mockState.instances.length = 0 diff --git a/src/main/ssh/ssh-connection-manager.ts b/src/main/ssh/ssh-connection-manager.ts index aad5bdde5613..e553bf906b59 100644 --- a/src/main/ssh/ssh-connection-manager.ts +++ b/src/main/ssh/ssh-connection-manager.ts @@ -6,46 +6,17 @@ import { SshConnection, type SshConnectionCallbacks } from './ssh-connection' // 300-line oxlint max-lines threshold while preserving a clear // single-responsibility boundary (connection lifecycle vs. pool management). -// Bounds an owned drain so a host that never confirms close cannot hang its caller. -const OWNED_CONNECTION_DRAIN_TIMEOUT_MS = 10_000 - export class SshConnectionManager { private connections = new Map() private callbacks: SshConnectionCallbacks // Why: attempt identity lets disconnect unblock a replacement without the // cancelled attempt later clearing the replacement's state. private connectingTargets = new Map() - // Local operation and teardown bookkeeping; never a verdict on remote process exit. - private pendingTargetOperations = new Map() - private unconfirmedTargetTeardowns = new Set() - // Counts every connection a target allocated, retired pool entries included, until it closes. - private readonly unclosedTransportsByTarget = new Map() constructor(callbacks: SshConnectionCallbacks) { this.callbacks = callbacks } - private registerConnection(targetId: string, connection: SshConnection): void { - this.unclosedTransportsByTarget.set( - targetId, - (this.unclosedTransportsByTarget.get(targetId) ?? 0) + 1 - ) - let observed = false - connection.subscribeTransportClosure(() => { - if (observed) { - return - } - observed = true - const remaining = (this.unclosedTransportsByTarget.get(targetId) ?? 1) - 1 - if (remaining === 0) { - this.unclosedTransportsByTarget.delete(targetId) - } else { - this.unclosedTransportsByTarget.set(targetId, remaining) - } - }) - this.connections.set(targetId, connection) - } - setCallbacks(callbacks: SshConnectionCallbacks): void { this.callbacks = callbacks for (const connection of this.connections.values()) { @@ -54,10 +25,6 @@ export class SshConnectionManager { } async connect(target: SshTarget): Promise { - return this.trackTargetOperation(target.id, () => this.connectTarget(target)) - } - - private async connectTarget(target: SshTarget): Promise { const existing = this.connections.get(target.id) if (existing?.getState().status === 'connected') { return existing @@ -76,16 +43,13 @@ export class SshConnectionManager { } const conn = new SshConnection(target, this.callbacks) - this.registerConnection(target.id, conn) + this.connections.set(target.id, conn) try { await conn.connect() } catch (err) { - // Why: a failed startup can still hold sockets, so it is disconnected, not just forgotten. - try { - await this.disconnectConnection(target.id, conn) - } catch (cleanupError) { - throw new AggregateError([err, cleanupError], 'ssh_connection_startup_cleanup_failed') + if (this.connections.get(target.id) === conn) { + this.connections.delete(target.id) } throw err } @@ -99,29 +63,6 @@ export class SshConnectionManager { } async disconnect(targetId: string): Promise { - return this.trackTargetOperation(targetId, () => this.disconnectTarget(targetId), true) - } - - /** Drains only the registered connection for this target; local closure is not remote exit. */ - async disconnectAndDrain(targetId: string, signal: AbortSignal): Promise { - return this.trackTargetOperation( - targetId, - async () => { - this.connectingTargets.delete(targetId) - const conn = this.connections.get(targetId) - if (!conn) { - return - } - await conn.disconnectAndDrain(signal) - if (this.connections.get(targetId) === conn) { - this.connections.delete(targetId) - } - }, - true - ) - } - - private async disconnectTarget(targetId: string): Promise { // Why: disconnect invalidates the old attempt immediately so a reconnect // need not wait for the cancelled socket's late completion. this.connectingTargets.delete(targetId) @@ -140,19 +81,11 @@ export class SshConnectionManager { * Why: a cancelled connect whose transport opened late owns that exact connection — disconnecting * by target id would tear down the replacement's live transport instead. */ - async disconnectConnection(targetId: string, conn: SshConnection, drain = false): Promise { - return this.trackTargetOperation( - targetId, - async () => { - await (drain - ? conn.disconnectAndDrain(AbortSignal.timeout(OWNED_CONNECTION_DRAIN_TIMEOUT_MS)) - : conn.disconnect()) - if (this.connections.get(targetId) === conn) { - this.connections.delete(targetId) - } - }, - true - ) + async disconnectConnection(targetId: string, conn: SshConnection): Promise { + await conn.disconnect() + if (this.connections.get(targetId) === conn) { + this.connections.delete(targetId) + } } async reconnect(targetId: string): Promise { @@ -160,33 +93,7 @@ export class SshConnectionManager { if (!conn) { return } - await this.trackTargetOperation(targetId, () => conn.reconnect()) - } - - private async trackTargetOperation( - targetId: string, - operation: () => Promise, - retainFailure = false - ): Promise { - this.pendingTargetOperations.set( - targetId, - (this.pendingTargetOperations.get(targetId) ?? 0) + 1 - ) - try { - return await operation() - } catch (error) { - if (retainFailure) { - this.unconfirmedTargetTeardowns.add(targetId) - } - throw error - } finally { - const remaining = (this.pendingTargetOperations.get(targetId) ?? 1) - 1 - if (remaining === 0) { - this.pendingTargetOperations.delete(targetId) - } else { - this.pendingTargetOperations.set(targetId, remaining) - } - } + await conn.reconnect() } getConnection(targetId: string): SshConnection | undefined { @@ -205,27 +112,9 @@ export class SshConnectionManager { return states } - async disconnectAll(shouldDisconnect: (targetId: string) => boolean = () => true): Promise { - await Promise.allSettled( - Array.from(this.connections).map(async ([targetId, connection]) => { - if (!shouldDisconnect(targetId)) { - return - } - await this.trackTargetOperation( - targetId, - async () => { - try { - await connection.disconnect() - } finally { - // A later registration or an excluded target is not this drain's to remove. - if (this.connections.get(targetId) === connection) { - this.connections.delete(targetId) - } - } - }, - true - ) - }) - ) + async disconnectAll(): Promise { + const disconnects = Array.from(this.connections.values()).map((c) => c.disconnect()) + await Promise.allSettled(disconnects) + this.connections.clear() } } diff --git a/src/main/ssh/ssh-connection-store-test-fixture.ts b/src/main/ssh/ssh-connection-store-test-fixture.ts deleted file mode 100644 index 1908112a8ed8..000000000000 --- a/src/main/ssh/ssh-connection-store-test-fixture.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { vi } from 'vitest' -import type { FolderWorkspace } from '../../shared/folder-workspace-types' -import type { Repo } from '../../shared/repo-types' -import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types' - -export function createMockStore() { - const targets: SshTarget[] = [] - const repos: Repo[] = [] - const folderWorkspaces: FolderWorkspace[] = [] - const projectGroups: { id: string; name: string; connectionId?: string | null }[] = [] - const leases: { - state: 'attached' | 'detached' | 'terminated' | 'expired' - ptyId?: string - worktreeId?: string - tabId?: string - leafId?: string - updatedAt?: number - }[] = [] - let deletedAliases: string[] = [] - const removedTombstones: RemovedSshTargetTombstone[] = [] - const reassignments: { oldTargetId: string; newTargetId: string }[] = [] - let generationCounter = 0 - - const dropTombstone = (oldTargetId: string) => { - const kept = removedTombstones.filter((t) => t.oldTargetId !== oldTargetId) - removedTombstones.length = 0 - removedTombstones.push(...kept) - } - - return { - allocateSshTargetGeneration: vi.fn(() => { - generationCounter += 1 - return generationCounter - }), - getSshTargets: vi.fn(() => [...targets]), - getSshTarget: vi.fn((id: string) => targets.find((t) => t.id === id)), - getRepos: vi.fn(() => [...repos]), - getProjectGroups: vi.fn(() => [...projectGroups]), - getFolderWorkspaces: vi.fn(() => [...folderWorkspaces]), - getSshRemotePtyLeases: vi.fn(() => [...leases]), - inspectOrcadMigrationSourceDependencies: vi.fn(() => ({ - totalCount: 0, - counts: { - automation: 0, - 'automation-run': 0, - 'mobile-tab-selection': 0, - 'retired-worktree-name': 0, - 'saved-port-forward': 0, - 'sparse-preset': 0, - 'terminal-lease': 0, - 'terminal-recovery': 0, - 'ui-routing': 0, - 'workspace-lineage': 0, - 'workspace-session': 0, - 'worktree-lineage': 0, - 'worktree-metadata': 0 - } - })), - addSshTarget: vi.fn((target: SshTarget) => targets.push(target)), - updateSshTarget: vi.fn((id: string, updates: Partial>) => { - const target = targets.find((t) => t.id === id) - if (!target) { - return null - } - Object.assign(target, updates) - return { ...target } - }), - removeSshTarget: vi.fn((id: string) => { - const idx = targets.findIndex((t) => t.id === id) - if (idx !== -1) { - targets.splice(idx, 1) - } - }), - getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]), - addDeletedSshConfigAlias: vi.fn((alias: string) => { - if (!deletedAliases.includes(alias)) { - deletedAliases.push(alias) - } - }), - removeDeletedSshConfigAlias: vi.fn((alias: string) => { - deletedAliases = deletedAliases.filter((entry) => entry !== alias) - }), - clearDeletedSshConfigAliases: vi.fn(() => { - deletedAliases = [] - }), - removedTombstones, - reassignments, - repos, - projectGroups, - folderWorkspaces, - leases, - getRemovedSshTargetTombstones: vi.fn(() => [...removedTombstones]), - addRemovedSshTargetTombstone: vi.fn((tombstone: RemovedSshTargetTombstone) => { - const filtered = removedTombstones.filter((t) => t.oldTargetId !== tombstone.oldTargetId) - removedTombstones.length = 0 - removedTombstones.push(...filtered, tombstone) - }), - removeRemovedSshTargetTombstone: vi.fn(dropTombstone), - // Nothing in this mock stores automations, so releasing always drops. - releaseRemovedSshTargetTombstone: vi.fn(dropTombstone), - reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => { - reassignments.push({ oldTargetId, newTargetId }) - // Pretend one repo referenced the old id. - return ['repo-1'] - }) - } -} diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index fc2b39e88af9..24845d2d3736 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshConnectionStore } from './ssh-connection-store' -import { createMockStore } from './ssh-connection-store-test-fixture' -import type { SshTarget } from '../../shared/ssh-types' +import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types' const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ loadUserSshConfigMock: vi.fn(), @@ -13,6 +12,72 @@ vi.mock('./ssh-config-parser', () => ({ sshConfigHostsToTargets: sshConfigHostsToTargetsMock })) +function createMockStore() { + const targets: SshTarget[] = [] + let deletedAliases: string[] = [] + const removedTombstones: RemovedSshTargetTombstone[] = [] + const reassignments: { oldTargetId: string; newTargetId: string }[] = [] + let generationCounter = 0 + + const dropTombstone = (oldTargetId: string) => { + const kept = removedTombstones.filter((t) => t.oldTargetId !== oldTargetId) + removedTombstones.length = 0 + removedTombstones.push(...kept) + } + + return { + allocateSshTargetGeneration: vi.fn(() => { + generationCounter += 1 + return generationCounter + }), + getSshTargets: vi.fn(() => [...targets]), + getSshTarget: vi.fn((id: string) => targets.find((t) => t.id === id)), + addSshTarget: vi.fn((target: SshTarget) => targets.push(target)), + updateSshTarget: vi.fn((id: string, updates: Partial>) => { + const target = targets.find((t) => t.id === id) + if (!target) { + return null + } + Object.assign(target, updates) + return { ...target } + }), + removeSshTarget: vi.fn((id: string) => { + const idx = targets.findIndex((t) => t.id === id) + if (idx !== -1) { + targets.splice(idx, 1) + } + }), + getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]), + addDeletedSshConfigAlias: vi.fn((alias: string) => { + if (!deletedAliases.includes(alias)) { + deletedAliases.push(alias) + } + }), + removeDeletedSshConfigAlias: vi.fn((alias: string) => { + deletedAliases = deletedAliases.filter((entry) => entry !== alias) + }), + clearDeletedSshConfigAliases: vi.fn(() => { + deletedAliases = [] + }), + removedTombstones, + reassignments, + getRemovedSshTargetTombstones: vi.fn(() => [...removedTombstones]), + addRemovedSshTargetTombstone: vi.fn((tombstone: RemovedSshTargetTombstone) => { + const filtered = removedTombstones.filter((t) => t.oldTargetId !== tombstone.oldTargetId) + removedTombstones.length = 0 + removedTombstones.push(...filtered, tombstone) + }), + removeRemovedSshTargetTombstone: vi.fn(dropTombstone), + // Nothing in this mock stores automations, so releasing always drops. + releaseRemovedSshTargetTombstone: vi.fn(dropTombstone), + reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => { + reassignments.push({ oldTargetId, newTargetId }) + // Pretend one repo referenced the old id. + return ['repo-1'] + }) + } +} + describe('SshConnectionStore', () => { let mockStore: ReturnType let sshStore: SshConnectionStore diff --git a/src/main/ssh/ssh-provider-continuations.ts b/src/main/ssh/ssh-provider-continuations.ts deleted file mode 100644 index 418c1d896767..000000000000 --- a/src/main/ssh/ssh-provider-continuations.ts +++ /dev/null @@ -1,24 +0,0 @@ -const pendingByTarget = new Map>() - -/** Tracks local continuation settlement, never host execution or cancellation acknowledgment. */ -export async function runSshProviderContinuation( - targetId: string, - operation: () => Promise -): Promise { - const pending = pendingByTarget.get(targetId) ?? new Set() - const token = {} - pendingByTarget.set(targetId, pending) - pending.add(token) - try { - return await operation() - } finally { - pending.delete(token) - if (pending.size === 0 && pendingByTarget.get(targetId) === pending) { - pendingByTarget.delete(targetId) - } - } -} - -export function hasSshProviderContinuations(targetId: string): boolean { - return (pendingByTarget.get(targetId)?.size ?? 0) > 0 -} diff --git a/src/main/ssh/ssh-target-registry.ts b/src/main/ssh/ssh-target-registry.ts index aef003025ccc..9959e8072a6d 100644 --- a/src/main/ssh/ssh-target-registry.ts +++ b/src/main/ssh/ssh-target-registry.ts @@ -90,7 +90,6 @@ export function getActiveMultiplexer(connectionId: string): SshChannelMultiplexe } let registeredGetSshConnectionManager: (() => SshConnectionManager | null) | null = null -let registeredHasDirectSshAuthority: ((targetId: string) => boolean) | null = null export function setSshConnectionManagerResolver( resolve: (() => SshConnectionManager | null) | null @@ -109,14 +108,3 @@ export function setSshConnectionManagerResolver( export function getSshConnectionManager(): SshConnectionManager | null { return registeredGetSshConnectionManager?.() ?? null } - -export function setDirectSshAuthorityResolver( - resolve: ((targetId: string) => boolean) | null -): void { - registeredHasDirectSshAuthority = resolve -} - -/** Whether this process currently holds the target's relay session; false when unregistered. */ -export function hasRegisteredDirectSshAuthority(targetId: string): boolean { - return registeredHasDirectSshAuthority?.(targetId) ?? false -} From 840aeae801d772af32aca15dffdb7bd19551621f Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:03 -0700 Subject: [PATCH 11/26] Revert "feat(daemon): idle retirement, session census and recovery-only provider (#16741 T2 P4b) (#24409)" This reverts commit ff212dbbeffebe7d947763ee0bbb0d5ef00bd40c. --- src/main/daemon/daemon-health-identity.ts | 13 - src/main/daemon/daemon-health.test.ts | 54 +--- src/main/daemon/daemon-health.ts | 77 +----- src/main/daemon/daemon-idle-shutdown.test.ts | 29 --- src/main/daemon/daemon-init.ts | 2 - .../daemon/daemon-provider-census.test.ts | 42 --- src/main/daemon/daemon-provider-state.ts | 83 ++---- .../daemon/daemon-pty-event-subscriptions.ts | 69 +---- .../daemon/daemon-pty-process-inspection.ts | 2 +- .../daemon/daemon-pty-router-test-fixture.ts | 167 ------------ src/main/daemon/daemon-pty-router.test.ts | 240 ++++++++++++------ src/main/daemon/daemon-pty-router.ts | 71 +++--- src/main/daemon/daemon-pty-runtime-state.ts | 14 +- src/main/daemon/daemon-pty-session-spawn.ts | 9 - .../daemon-recovery-only-adapter.test.ts | 103 -------- .../daemon/daemon-recovery-provider-init.ts | 10 - .../daemon/daemon-recovery-provider.test.ts | 54 ---- src/main/daemon/daemon-recovery-provider.ts | 41 --- .../daemon/daemon-recovery-spawn-admission.ts | 21 -- src/main/daemon/daemon-request-router.ts | 3 +- .../daemon/daemon-router-retirement.test.ts | 49 ---- src/main/daemon/daemon-router-retirement.ts | 74 ------ .../daemon-router-session-reconciliation.ts | 37 --- 23 files changed, 235 insertions(+), 1029 deletions(-) delete mode 100644 src/main/daemon/daemon-health-identity.ts delete mode 100644 src/main/daemon/daemon-provider-census.test.ts delete mode 100644 src/main/daemon/daemon-pty-router-test-fixture.ts delete mode 100644 src/main/daemon/daemon-recovery-only-adapter.test.ts delete mode 100644 src/main/daemon/daemon-recovery-provider-init.ts delete mode 100644 src/main/daemon/daemon-recovery-provider.test.ts delete mode 100644 src/main/daemon/daemon-recovery-provider.ts delete mode 100644 src/main/daemon/daemon-recovery-spawn-admission.ts delete mode 100644 src/main/daemon/daemon-router-retirement.test.ts delete mode 100644 src/main/daemon/daemon-router-retirement.ts delete mode 100644 src/main/daemon/daemon-router-session-reconciliation.ts diff --git a/src/main/daemon/daemon-health-identity.ts b/src/main/daemon/daemon-health-identity.ts deleted file mode 100644 index 44a1c02978eb..000000000000 --- a/src/main/daemon/daemon-health-identity.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** What a `ptySpawnHealth` reply proves about this daemon; every field is optional on the wire. */ -export function readDaemonHealthIdentity(): { - coverage: 'pty-spawn' | 'handshake' - runtimeKind: 'node' - runtimeVersion: string -} { - return { - // Why handshake on Windows: preflightPtySpawnHealth skips the spawn probe there. - coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn', - runtimeKind: 'node', - runtimeVersion: process.version - } -} diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 5b00b7c91e53..c499fadd5518 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -11,7 +11,6 @@ import { getDaemonPidPath, serializeDaemonPidFile } from './daemon-spawner' import type { SocketProbeOutcome } from './daemon-endpoint-probe' import { checkDaemonHealth, - checkDaemonHealthWithCoverage, E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV, healthCheckDaemon } from './daemon-health' @@ -106,63 +105,12 @@ describe('daemon health', () => { try { await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('healthy') await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true) - await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toMatchObject({ - verdict: 'healthy', - coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn', - runtimeKind: 'node', - runtimeVersion: process.version - }) - expect(ptySpawnHealthCheck).toHaveBeenCalledTimes(3) + expect(ptySpawnHealthCheck).toHaveBeenCalledTimes(2) } finally { await server.shutdown() } }) - it('treats missing coverage from a legacy Windows daemon as handshake-only', async () => { - writeFileSync(tokenPath, 'legacy-token') - const server = createServer((socket) => { - let pending = '' - socket.on('data', (chunk) => { - pending += chunk.toString() - for (;;) { - const newline = pending.indexOf('\n') - if (newline === -1) { - return - } - const message: unknown = JSON.parse(pending.slice(0, newline)) - const type = - typeof message === 'object' && message !== null && 'type' in message - ? message.type - : undefined - pending = pending.slice(newline + 1) - if (type === 'hello') { - socket.write(`${JSON.stringify({ type: 'hello', ok: true })}\n`) - } else if (type === 'ptySpawnHealth') { - socket.write( - `${JSON.stringify({ id: 'health-1', ok: true, payload: { healthy: true } })}\n` - ) - } - } - }) - }) - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(socketPath, resolve) - }) - - const platform = Object.getOwnPropertyDescriptor(process, 'platform')! - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - try { - await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toEqual({ - verdict: 'healthy', - coverage: 'handshake' - }) - } finally { - Object.defineProperty(process, 'platform', platform) - await closeServer(server) - } - }) - it('fails when a protocol-healthy daemon cannot spawn PTYs', async () => { const server = new DaemonServer({ socketPath, diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index cc035c26a085..d3a2c8a91a93 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -21,57 +21,15 @@ export const E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV = 'ORCA_E2E_FORCE_DAEMON_HE // also covers a live-but-wedged daemon that simply missed the RPC budget. export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy' -export type DaemonHealthCheck = { - verdict: DaemonHealth - coverage: 'pty-spawn' | 'handshake' - /** Optional runtime proof from newer daemons; absent on mixed-version peers. */ - runtimeKind?: 'node' - runtimeVersion?: string -} - -function readRuntimeIdentity( - payload: unknown -): Pick { - if (typeof payload !== 'object' || payload === null) { - return {} - } - const runtimeKind = 'runtimeKind' in payload ? payload.runtimeKind : undefined - const runtimeVersion = 'runtimeVersion' in payload ? payload.runtimeVersion : undefined - // Why drop other kinds: only a Node daemon reports here; anything else is an unknown peer. - return { - ...(runtimeKind === 'node' ? { runtimeKind } : {}), - ...(typeof runtimeVersion === 'string' && runtimeVersion.length > 0 ? { runtimeVersion } : {}) - } -} - -function readPtySpawnHealthCoverage( - payload: unknown, - fallback: DaemonHealthCheck['coverage'] -): DaemonHealthCheck['coverage'] { - if (typeof payload !== 'object' || payload === null) { - return fallback - } - const coverage = 'coverage' in payload ? payload.coverage : undefined - return coverage === 'pty-spawn' || coverage === 'handshake' ? coverage : fallback -} - -export function checkDaemonHealthWithCoverage( - socketPath: string, - tokenPath: string -): Promise { +export function checkDaemonHealth(socketPath: string, tokenPath: string): Promise { return new Promise((resolve) => { - // Older Windows daemons answered this RPC without spawning; an absent optional coverage - // field must preserve that weaker meaning during adoption. - const fallbackCoverage = process.platform === 'win32' ? 'handshake' : 'pty-spawn' - const resolveVerdict = (verdict: DaemonHealth): void => - resolve({ verdict, coverage: fallbackCoverage }) if (process.env[E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV] === '1') { - resolveVerdict('unreachable') + resolve('unreachable') return } if (process.platform !== 'win32' && !existsSync(socketPath)) { - resolveVerdict('unreachable') + resolve('unreachable') return } @@ -79,13 +37,13 @@ export function checkDaemonHealthWithCoverage( try { token = readFileSync(tokenPath, 'utf8').trim() } catch { - resolveVerdict('unreachable') + resolve('unreachable') return } let settled = false let sock: Socket | null = null - const settle = (result: DaemonHealthCheck): void => { + const settle = (result: DaemonHealth): void => { if (settled) { return } @@ -100,7 +58,7 @@ export function checkDaemonHealthWithCoverage( sock?.off('connect', onConnect) sock?.off('data', onData) } - const onError = (): void => settle({ verdict: 'unreachable', coverage: fallbackCoverage }) + const onError = (): void => settle('unreachable') const onConnect = (): void => { const hello: HelloMessage = { type: 'hello', @@ -131,13 +89,13 @@ export function checkDaemonHealthWithCoverage( try { message = JSON.parse(line) as Record } catch { - settle({ verdict: 'rejected', coverage: fallbackCoverage }) + settle('rejected') return } if (message.type === 'hello') { if (!(message as HelloResponse).ok) { - settle({ verdict: 'rejected', coverage: fallbackCoverage }) + settle('rejected') return } // Why: a protocol-live daemon with a stale cwd or node-pty helper @@ -148,20 +106,12 @@ export function checkDaemonHealthWithCoverage( } if (message.id === 'health-1') { - const identity = readRuntimeIdentity(message.payload) - settle({ - verdict: message.ok === true ? 'healthy' : 'pty-spawn-unhealthy', - coverage: readPtySpawnHealthCoverage(message.payload, fallbackCoverage), - ...identity - }) + settle(message.ok === true ? 'healthy' : 'pty-spawn-unhealthy') return } } } - const timer = setTimeout( - () => settle({ verdict: 'unreachable', coverage: fallbackCoverage }), - HEALTH_CHECK_TIMEOUT_MS - ) + const timer = setTimeout(() => settle('unreachable'), HEALTH_CHECK_TIMEOUT_MS) sock = connect({ path: socketPath }) sock.on('error', onError) @@ -172,13 +122,6 @@ export function checkDaemonHealthWithCoverage( }) } -export async function checkDaemonHealth( - socketPath: string, - tokenPath: string -): Promise { - return (await checkDaemonHealthWithCoverage(socketPath, tokenPath)).verdict -} - export async function healthCheckDaemon(socketPath: string, tokenPath: string): Promise { return (await checkDaemonHealth(socketPath, tokenPath)) === 'healthy' } diff --git a/src/main/daemon/daemon-idle-shutdown.test.ts b/src/main/daemon/daemon-idle-shutdown.test.ts index 43a82df72bb1..1500718e98fd 100644 --- a/src/main/daemon/daemon-idle-shutdown.test.ts +++ b/src/main/daemon/daemon-idle-shutdown.test.ts @@ -442,35 +442,6 @@ describe('current daemon lifecycle retirement', () => { adopted.dispose() }) - it('atomically retires an idle daemon and permanently fences adapter spawns', async () => { - await startServer() - const adapter = new DaemonPtyAdapter({ socketPath, tokenPath }) - - await expect(adapter.requestIdleRetirement()).resolves.toEqual({ state: 'retiring' }) - await expect( - adapter.spawn({ sessionId: 'late-after-decommission', cols: 80, rows: 24 }) - ).rejects.toThrow('Terminal daemon is decommissioning') - await waitFor(() => onIdleShutdown.mock.calls.length === 1) - adapter.dispose() - }) - - it('reopens adapter admission when the daemon refuses retirement for a live session', async () => { - await startServer() - const adapter = new DaemonPtyAdapter({ socketPath, tokenPath }) - await adapter.spawn({ sessionId: 'already-live', cols: 80, rows: 24 }) - - await expect(adapter.requestIdleRetirement()).resolves.toEqual({ - state: 'busy', - liveSessions: 1, - admissionReopened: true - }) - await expect( - adapter.spawn({ sessionId: 'allowed-after-refusal', cols: 80, rows: 24 }) - ).resolves.toMatchObject({ id: 'allowed-after-refusal' }) - expect(onIdleShutdown).not.toHaveBeenCalled() - adapter.dispose() - }) - it('does not let repeated authenticated control probes extend the startup deadline', async () => { await startServer() const healthControl = connect(socketPath) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 52e6b2654b0f..918547d8b79d 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -8,8 +8,6 @@ export { getDaemonEndpointFacts, getDaemonProvider, listLiveDaemonPtyIds, - listLiveDaemonSessions, - requestIdleDaemonRetirement, readDaemonPidRecord, replaceDaemonProvider, shutdownDaemon, diff --git a/src/main/daemon/daemon-provider-census.test.ts b/src/main/daemon/daemon-provider-census.test.ts deleted file mode 100644 index 2b0d6f88ac89..000000000000 --- a/src/main/daemon/daemon-provider-census.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' - -vi.mock('../ipc/pty', () => ({ setLocalPtyProvider: vi.fn() })) - -import { DaemonPtyRouter } from './daemon-pty-router' -import { createAdapter } from './daemon-pty-router-test-fixture' -import { - disconnectDaemon, - listLiveDaemonSessions, - replaceDaemonProvider, - requestIdleDaemonRetirement -} from './daemon-provider-state' -import { PROTOCOL_VERSION } from './types' - -afterEach(async () => { - await disconnectDaemon() -}) - -it('reads a census without an installed daemon as unverifiable, never as empty', async () => { - await expect(listLiveDaemonSessions()).resolves.toBeNull() - await expect(requestIdleDaemonRetirement()).resolves.toEqual({ state: 'unverifiable' }) -}) - -it('reads a census with an unanswered generation as unverifiable', async () => { - const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - vi.mocked(legacy.listSessions).mockRejectedValue(new Error('daemon unreachable')) - replaceDaemonProvider(new DaemonPtyRouter({ current, legacy: [legacy] })) - - await expect(listLiveDaemonSessions()).resolves.toBeNull() -}) - -it('lists every generation when each one answers', async () => { - const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', ['live-2'], undefined, PROTOCOL_VERSION) - replaceDaemonProvider(new DaemonPtyRouter({ current, legacy: [legacy] })) - - await expect(listLiveDaemonSessions()).resolves.toEqual([ - { sessionId: 'live-1', isAlive: true }, - { sessionId: 'live-2', isAlive: true } - ]) -}) diff --git a/src/main/daemon/daemon-provider-state.ts b/src/main/daemon/daemon-provider-state.ts index d981e4f7774a..412dda3af83e 100644 --- a/src/main/daemon/daemon-provider-state.ts +++ b/src/main/daemon/daemon-provider-state.ts @@ -11,16 +11,12 @@ import { getMacDaemonTccAttributionHealth, type MacDaemonTccAttributionHealth } from './daemon-tcc-attribution' -import { PROTOCOL_VERSION, type SessionInfo } from './types' -import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state' +import { PROTOCOL_VERSION } from './types' let spawner: DaemonSpawner | null = null let adapter: DaemonProvider | null = null -export function installDaemonProvider( - newSpawner: DaemonSpawner | null, - newAdapter: DaemonProvider -): void { +export function installDaemonProvider(newSpawner: DaemonSpawner, newAdapter: DaemonProvider): void { spawner = newSpawner replaceDaemonProvider(newAdapter) } @@ -39,12 +35,7 @@ export function getDaemonSpawner(): DaemonSpawner | null { * from that state would be advertising recovery for terminals that cannot be recovered. */ export function daemonOwnsFreshPersistentPtys(): boolean { - if (!adapter || adapter instanceof DegradedDaemonPtyProvider) { - return false - } - return adapter instanceof DaemonPtyRouter - ? !adapter.getAllAdapters().some((entry) => entry.recoveryOnly) - : !adapter.recoveryOnly + return adapter !== null && !(adapter instanceof DegradedDaemonPtyProvider) } /** Endpoint coordinates of the daemon this process installed, for out-of-band health probes. */ @@ -104,27 +95,6 @@ export async function getCurrentDaemonMacTccAttributionHealth(): Promise { - await adapter?.disconnectOnly() - adapter = null -} - -/** Kill the daemon and all its sessions. Use for full cleanup only. */ -export async function shutdownDaemon(): Promise { - adapter?.dispose() - adapter = null - await spawner?.shutdown() - spawner = null -} - /** Returns null unless every daemon generation supplied an authoritative inventory. */ export async function listLiveDaemonPtyIds(): Promise { if (!adapter) { @@ -145,36 +115,23 @@ export async function listLiveDaemonPtyIds(): Promise { ) } -/** Returns null unless every daemon generation supplied an authoritative session inventory. */ -export async function listLiveDaemonSessions(): Promise { - if (!adapter) { - return null - } - const adapters = - adapter instanceof DaemonPtyRouter || adapter instanceof DegradedDaemonPtyProvider - ? adapter.getAllAdapters() - : [adapter] - const inventories = await Promise.allSettled( - adapters.map((daemonAdapter) => daemonAdapter.listSessions()) - ) - if (inventories.some((inventory) => inventory.status === 'rejected')) { - return null - } - return inventories.flatMap((inventory) => - inventory.status === 'fulfilled' ? inventory.value : [] - ) +// Why: keep the module-level adapter and ipc/pty.ts's localProvider in sync so app-quit can't dispose a stale reference. +export function replaceDaemonProvider(newAdapter: DaemonProvider): void { + adapter = newAdapter + setLocalPtyProvider(newAdapter) } -/** Atomically fence new daemon terminals and retire only an idle, single-generation daemon. */ -export async function requestIdleDaemonRetirement(): Promise { - if (!adapter) { - return { state: 'unverifiable' } - } - if (adapter instanceof DegradedDaemonPtyProvider) { - return { state: 'unverifiable' } - } - if (adapter instanceof DaemonPtyRouter) { - return adapter.requestIdleRetirement() - } - return adapter.requestIdleRetirement() +// Disconnect without killing: the daemon survives app quit so sessions stay warm for reattach. +// Leave history sessions marked "unclean" so a daemon crash while Orca is closed stays recoverable. +export async function disconnectDaemon(): Promise { + await adapter?.disconnectOnly() + adapter = null +} + +/** Kill the daemon and all its sessions. Use for full cleanup only. */ +export async function shutdownDaemon(): Promise { + adapter?.dispose() + adapter = null + await spawner?.shutdown() + spawner = null } diff --git a/src/main/daemon/daemon-pty-event-subscriptions.ts b/src/main/daemon/daemon-pty-event-subscriptions.ts index a1b1e1acfc4b..6d95e2de1c7c 100644 --- a/src/main/daemon/daemon-pty-event-subscriptions.ts +++ b/src/main/daemon/daemon-pty-event-subscriptions.ts @@ -3,12 +3,7 @@ import { removeDaemonListener } from './daemon-listener-registry' import { emitPtyListeners } from './daemon-pty-listener-emission' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import { DaemonPtySessionInventory } from './daemon-pty-session-inventory' -import { - CLEAN_DISCONNECT_PROTOCOL_VERSION, - type ListSessionsResult, - type ShutdownIfIdleResult -} from './types' -import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state' +import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types' import type { PtyBackgroundStreamEvent } from '../providers/types' export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInventory { @@ -90,68 +85,6 @@ export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInvent this.recordAuthenticatedIdentity() } - async requestIdleRetirement(): Promise { - if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) { - return { state: 'unsupported' } - } - if (this.idleRetirementState === 'retiring') { - return { state: 'retiring' } - } - if (this.idleRetirementPromise) { - return this.idleRetirementPromise - } - if ( - this.disconnectOnlyPromise || - (this.respawnAdoptionClosed && this.idleRetirementState === 'open') - ) { - return { state: 'unverifiable' } - } - this.idleRetirementAdmissionClosed = true - this.respawnAdoptionClosed = true - this.idleRetirementState = 'checking' - const request = this.finishIdleRetirementRequest().finally(() => { - if (this.idleRetirementPromise === request) { - this.idleRetirementPromise = null - } - }) - this.idleRetirementPromise = request - return request - } - - private async finishIdleRetirementRequest(): Promise { - try { - await this.client.ensureConnected() - const result = await this.client.request('shutdownIfIdle', undefined) - if (result.retiring) { - this.idleRetirementState = 'retiring' - return { state: 'retiring' } - } - let liveSessions: number | null = null - try { - const inventory = await this.client.request('listSessions', undefined) - liveSessions = inventory.sessions.filter((session) => session.isAlive).length - } catch { - liveSessions = null - } - this.reopenAfterRefusedIdleRetirement() - return { - state: 'busy', - liveSessions, - ...(!this.recoveryOnly ? { admissionReopened: true as const } : {}) - } - } catch { - // The daemon may have accepted before contact was lost; keep admission and respawn fenced. - this.idleRetirementState = 'unverifiable' - return { state: 'unverifiable' } - } - } - - private reopenAfterRefusedIdleRetirement(): void { - this.idleRetirementState = 'open' - this.idleRetirementAdmissionClosed = false - this.respawnAdoptionClosed = false - } - // Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable, // but still write a final checkpoint so a daemon crash while Orca is closed has recovery data. async disconnectOnly(): Promise { diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts index cc8f8da849a6..335c641da627 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -127,7 +127,7 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot // Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan. const { worktreeId } = parsePtySessionId(session.sessionId) - if (!this.recoveryOnly && (worktreeId === null || !validWorktreeIds.has(worktreeId))) { + if (worktreeId === null || !validWorktreeIds.has(worktreeId)) { try { await this.client.request('kill', { sessionId: session.sessionId }) } catch { diff --git a/src/main/daemon/daemon-pty-router-test-fixture.ts b/src/main/daemon/daemon-pty-router-test-fixture.ts deleted file mode 100644 index ded57c76b14f..000000000000 --- a/src/main/daemon/daemon-pty-router-test-fixture.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { vi } from 'vitest' -import { settledWriteStub } from '../providers/settled-pty-write-stub' -import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types' -import { - AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, - GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION -} from './types' -import { SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' - -type AdapterMock = DaemonPtyAdapter & { - emitData: (id: string, data: string, sequenceChars?: number) => void - emitBackground: (event: PtyBackgroundStreamEvent) => void - emitExit: (id: string, code: number, incarnationId?: string) => void - emitIdentityChange: () => void - triggerWriteUnavailable: (id: string) => void -} - -export function createAdapter( - label: string, - sessions: string[] = [], - reconcileResult?: { alive: string[]; killed: string[] }, - protocolVersion = GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION -): AdapterMock { - const writes: { id: string; data: string }[] = [] - const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = - [] - const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] - const writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] - const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] = - [] - const identityChangeListeners: (() => void)[] = [] - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the router calls only the adapter members this mock defines. - return { - protocolVersion, - supportsGitCredentialGuardHost: () => - protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, - supportsAgentSessionClaims: () => - protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - supportsAgentSessionCreateOperations: () => - protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, - providesAgentSessionOwnerListings: () => - protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - canProvideAuthoritativeBufferSnapshot: () => - protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, - spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { - const id = opts.sessionId ?? `${label}-new` - sessions.push(id) - return { id } - }), - listProcesses: vi.fn(async () => - sessions.map((id) => ({ - id, - cwd: '', - title: label - })) - ), - listSessions: vi.fn(async () => sessions.map((sessionId) => ({ sessionId, isAlive: true }))), - requestIdleRetirement: vi.fn(async () => ({ state: 'retiring' as const })), - hasPty: vi.fn((id: string) => sessions.includes(id)), - probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)), - write: vi.fn((id: string, data: string) => { - writes.push({ id, data }) - }), - writeWithSettlement: vi.fn(settledWriteStub()), - resize: vi.fn(), - setPtyBackgrounded: vi.fn(), - getBufferSnapshot: vi.fn(async () => null), - shutdown: vi.fn(async (id: string) => { - const idx = sessions.indexOf(id) - if (idx !== -1) { - sessions.splice(idx, 1) - } - }), - attach: vi.fn(async () => {}), - sendSignal: vi.fn(async () => {}), - getCwd: vi.fn(async () => ''), - getInitialCwd: vi.fn(async () => ''), - clearBuffer: vi.fn(async () => {}), - acknowledgeDataEvent: vi.fn(), - hasChildProcesses: vi.fn(async () => false), - getForegroundProcess: vi.fn(async () => null), - inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false })), - confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), - serialize: vi.fn(async () => '{}'), - revive: vi.fn(async () => {}), - getDefaultShell: vi.fn(async () => '/bin/zsh'), - getProfiles: vi.fn(async () => []), - onData: vi.fn( - (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { - dataListeners.push(callback) - return () => { - const idx = dataListeners.indexOf(callback) - if (idx !== -1) { - dataListeners.splice(idx, 1) - } - } - } - ), - onBackgroundStreamEvent: vi.fn((callback: (payload: PtyBackgroundStreamEvent) => void) => { - backgroundListeners.push(callback) - return () => { - const idx = backgroundListeners.indexOf(callback) - if (idx !== -1) { - backgroundListeners.splice(idx, 1) - } - } - }), - onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { - writeUnavailableListeners.push(callback) - return () => { - const idx = writeUnavailableListeners.indexOf(callback) - if (idx !== -1) { - writeUnavailableListeners.splice(idx, 1) - } - } - }), - onExit: vi.fn( - (callback: (payload: { id: string; code: number; incarnationId?: string }) => void) => { - exitListeners.push(callback) - return () => { - const idx = exitListeners.indexOf(callback) - if (idx !== -1) { - exitListeners.splice(idx, 1) - } - } - } - ), - onDaemonIdentityChanged: vi.fn((callback: () => void) => { - identityChangeListeners.push(callback) - return () => { - const idx = identityChangeListeners.indexOf(callback) - if (idx !== -1) { - identityChangeListeners.splice(idx, 1) - } - } - }), - ackColdRestore: vi.fn(), - clearTombstone: vi.fn(), - reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }), - dispose: vi.fn(), - disconnectOnly: vi.fn(async () => {}), - emitData: (id: string, data: string, sequenceChars?: number) => { - for (const listener of dataListeners) { - listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) - } - }, - emitBackground: (event: PtyBackgroundStreamEvent) => { - for (const listener of backgroundListeners) { - listener(event) - } - }, - emitExit: (id: string, code: number, incarnationId?: string) => { - for (const listener of exitListeners) { - listener({ id, code, ...(incarnationId ? { incarnationId } : {}) }) - } - }, - emitIdentityChange: () => identityChangeListeners.forEach((listener) => listener()), - triggerWriteUnavailable: (id: string) => { - for (const listener of writeUnavailableListeners) { - listener({ id }) - } - }, - _writes: writes - } as unknown as AdapterMock -} diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 7e35d416b12e..61db990b21c6 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -1,20 +1,29 @@ -import { createAdapter } from './daemon-pty-router-test-fixture' import { describe, expect, it, vi } from 'vitest' import { DaemonPtyRouter } from './daemon-pty-router' -import { stubWriteSettlement } from '../providers/settled-pty-write-stub' import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { PtySpawnResult } from '../providers/types' +import { settledWriteStub, stubWriteSettlement } from '../providers/settled-pty-write-stub' +import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION + AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, + GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION } from './types' import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, PROTOCOL_VERSION, + SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' +type AdapterMock = DaemonPtyAdapter & { + emitData: (id: string, data: string, sequenceChars?: number) => void + emitBackground: (event: PtyBackgroundStreamEvent) => void + emitExit: (id: string, code: number, incarnationId?: string) => void + emitIdentityChange: () => void + triggerWriteUnavailable: (id: string) => void +} + const LARGE_RECONCILE_SESSION_COUNT = 150_000 function buildSessionIds(prefix: string, count: number): string[] { @@ -25,6 +34,152 @@ function buildSessionIds(prefix: string, count: number): string[] { return ids } +function createAdapter( + label: string, + sessions: string[] = [], + reconcileResult?: { alive: string[]; killed: string[] }, + protocolVersion = GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION +): AdapterMock { + const writes: { id: string; data: string }[] = [] + const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = + [] + const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] + const writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] + const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] = + [] + const identityChangeListeners: (() => void)[] = [] + return { + protocolVersion, + supportsGitCredentialGuardHost: () => + protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, + supportsAgentSessionClaims: () => + protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + supportsAgentSessionCreateOperations: () => + protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, + providesAgentSessionOwnerListings: () => + protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + canProvideAuthoritativeBufferSnapshot: () => + protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, + spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { + const id = opts.sessionId ?? `${label}-new` + sessions.push(id) + return { id } + }), + listProcesses: vi.fn(async () => + sessions.map((id) => ({ + id, + cwd: '', + title: label + })) + ), + hasPty: vi.fn((id: string) => sessions.includes(id)), + probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)), + write: vi.fn((id: string, data: string) => { + writes.push({ id, data }) + }), + writeWithSettlement: vi.fn(settledWriteStub()), + resize: vi.fn(), + setPtyBackgrounded: vi.fn(), + getBufferSnapshot: vi.fn(async () => null), + shutdown: vi.fn(async (id: string) => { + const idx = sessions.indexOf(id) + if (idx !== -1) { + sessions.splice(idx, 1) + } + }), + attach: vi.fn(async () => {}), + sendSignal: vi.fn(async () => {}), + getCwd: vi.fn(async () => ''), + getInitialCwd: vi.fn(async () => ''), + clearBuffer: vi.fn(async () => {}), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(async () => false), + getForegroundProcess: vi.fn(async () => null), + inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false })), + confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), + serialize: vi.fn(async () => '{}'), + revive: vi.fn(async () => {}), + getDefaultShell: vi.fn(async () => '/bin/zsh'), + getProfiles: vi.fn(async () => []), + onData: vi.fn( + (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { + dataListeners.push(callback) + return () => { + const idx = dataListeners.indexOf(callback) + if (idx !== -1) { + dataListeners.splice(idx, 1) + } + } + } + ), + onBackgroundStreamEvent: vi.fn((callback: (payload: PtyBackgroundStreamEvent) => void) => { + backgroundListeners.push(callback) + return () => { + const idx = backgroundListeners.indexOf(callback) + if (idx !== -1) { + backgroundListeners.splice(idx, 1) + } + } + }), + onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { + writeUnavailableListeners.push(callback) + return () => { + const idx = writeUnavailableListeners.indexOf(callback) + if (idx !== -1) { + writeUnavailableListeners.splice(idx, 1) + } + } + }), + onExit: vi.fn( + (callback: (payload: { id: string; code: number; incarnationId?: string }) => void) => { + exitListeners.push(callback) + return () => { + const idx = exitListeners.indexOf(callback) + if (idx !== -1) { + exitListeners.splice(idx, 1) + } + } + } + ), + onDaemonIdentityChanged: vi.fn((callback: () => void) => { + identityChangeListeners.push(callback) + return () => { + const idx = identityChangeListeners.indexOf(callback) + if (idx !== -1) { + identityChangeListeners.splice(idx, 1) + } + } + }), + ackColdRestore: vi.fn(), + clearTombstone: vi.fn(), + reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }), + dispose: vi.fn(), + disconnectOnly: vi.fn(async () => {}), + emitData: (id: string, data: string, sequenceChars?: number) => { + for (const listener of dataListeners) { + listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) + } + }, + emitBackground: (event: PtyBackgroundStreamEvent) => { + for (const listener of backgroundListeners) { + listener(event) + } + }, + emitExit: (id: string, code: number, incarnationId?: string) => { + for (const listener of exitListeners) { + listener({ id, code, ...(incarnationId ? { incarnationId } : {}) }) + } + }, + emitIdentityChange: () => identityChangeListeners.forEach((listener) => listener()), + triggerWriteUnavailable: (id: string) => { + for (const listener of writeUnavailableListeners) { + listener({ id }) + } + }, + _writes: writes + } as unknown as AdapterMock +} + it('forwards dead-endpoint write-unavailable signals from every routed adapter', () => { // Why revert-sensitive: main subscribes on the ROUTED provider, so if the router // does not forward this the STA-2373 fan-out never reaches the renderer and only @@ -92,83 +247,6 @@ it('forwards the owning legacy daemon sequence from attach', async () => { }) describe('DaemonPtyRouter', () => { - describe('idle retirement', () => { - it('retires every empty daemon generation and fences subsequent spawns', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - - await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'retiring' }) - expect(current.requestIdleRetirement).toHaveBeenCalledOnce() - expect(legacy.requestIdleRetirement).toHaveBeenCalledOnce() - await expect(router.spawn({ sessionId: 'late', cols: 80, rows: 24 })).rejects.toThrow( - 'Terminal daemon is decommissioning' - ) - }) - - it('reports live inventory before retiring any generation and reopens admission', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', ['legacy-live'], undefined, PROTOCOL_VERSION) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - - await expect(router.requestIdleRetirement()).resolves.toEqual({ - state: 'busy', - liveSessions: 1, - admissionReopened: true - }) - expect(current.requestIdleRetirement).not.toHaveBeenCalled() - expect(legacy.requestIdleRetirement).not.toHaveBeenCalled() - await expect( - router.spawn({ sessionId: 'after-refusal', cols: 80, rows: 24 }) - ).resolves.toEqual({ - id: 'after-refusal' - }) - }) - - it('does not partially retire when a generation predates clean idle shutdown', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, 23) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - - await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'unsupported' }) - expect(current.requestIdleRetirement).not.toHaveBeenCalled() - expect(legacy.requestIdleRetirement).not.toHaveBeenCalled() - }) - - it('keeps admission fenced after a partial multi-generation retirement', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - vi.mocked(legacy.requestIdleRetirement).mockResolvedValueOnce({ - state: 'busy', - liveSessions: 0 - }) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - - await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - await expect(router.spawn({ sessionId: 'unsafe', cols: 80, rows: 24 })).rejects.toThrow( - 'Terminal daemon is decommissioning' - ) - }) - - it('does not certify reopened admission when another generation retired beside live sessions', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - vi.mocked(legacy.requestIdleRetirement).mockResolvedValueOnce({ - state: 'busy', - liveSessions: 1, - admissionReopened: true - }) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - await expect(router.requestIdleRetirement()).resolves.toEqual({ - state: 'busy', - liveSessions: 1 - }) - await expect( - router.spawn({ sessionId: 'unsafe-partial', cols: 80, rows: 24 }) - ).rejects.toThrow('Terminal daemon is decommissioning') - }) - }) - it('reports separate conservative resume and fresh-create boundaries', () => { const current = createAdapter( 'current', diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index c5f242300f2e..0534c9d28697 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,4 +1,3 @@ -import { reconcileDaemonRouterSessions } from './daemon-router-session-reconciliation' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout' import type { @@ -13,8 +12,6 @@ import type { PtyProcessInspection } from '../providers/pty-process-inspection' import { shouldHandoffDaemonHistory } from './daemon-history-handoff' import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' -import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state' -import { DaemonRouterRetirement } from './daemon-router-retirement' import type { WriteSettlement } from '../../shared/pty-write-settlement' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' @@ -24,7 +21,6 @@ export class DaemonPtyRouter implements IPtyProvider { private sessionAdapters = new Map() private readonly ownerResolver: DaemonSessionOwnerResolver private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout - private readonly retirement = new DaemonRouterRetirement(() => this.allAdapters()) constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { this.current = opts.current @@ -44,30 +40,17 @@ export class DaemonPtyRouter implements IPtyProvider { } async spawn(opts: PtySpawnOptions): Promise { - if (this.retirement.admissionClosed) { - throw new Error('Terminal daemon is decommissioning') + if (opts.attachOnly && opts.sessionId) { + return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId }) } - // Why counted: an idle-retirement census must not race a spawn it cannot yet see. - this.retirement.spawnInFlight++ - try { - if (opts.attachOnly && opts.sessionId) { - return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId }) - } - const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined - const target = adapter ?? this.current - const result = await target.spawn(opts) - // Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof. - if (!result.exitedBeforeSpawnReply) { - this.ownerResolver.recordRoute(result.id, target, result.incarnationId) - } - return result - } finally { - this.retirement.spawnInFlight-- + const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined + const target = adapter ?? this.current + const result = await target.spawn(opts) + // Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof. + if (!result.exitedBeforeSpawnReply) { + this.ownerResolver.recordRoute(result.id, target, result.incarnationId) } - } - - requestIdleRetirement(): Promise { - return this.retirement.requestIdleRetirement() + return result } supportsGitCredentialGuardHost(sessionId?: string): boolean { @@ -274,10 +257,38 @@ export class DaemonPtyRouter implements IPtyProvider { this.adapterFor(sessionId).clearTombstone(sessionId) } - async reconcileOnStartup( - validWorktreeIds: Set - ): Promise<{ alive: string[]; killed: string[] }> { - return reconcileDaemonRouterSessions(this.allAdapters(), this.ownerResolver, validWorktreeIds) + async reconcileOnStartup(validWorktreeIds: Set): Promise<{ + alive: string[] + killed: string[] + }> { + const alive: string[] = [] + const killed: string[] = [] + const aliveProviders = new Map>() + for (const adapter of this.allAdapters()) { + const result = await adapter.reconcileOnStartup(validWorktreeIds) + // Why: daemon startup can reconcile many restored sessions; spreading + // those arrays into push can exceed JavaScript's argument limit. + for (const id of result.alive) { + alive.push(id) + } + for (const id of result.killed) { + killed.push(id) + } + for (const id of result.alive) { + const providers = aliveProviders.get(id) ?? new Set() + providers.add(adapter) + aliveProviders.set(id, providers) + } + } + for (const id of new Set([...alive, ...killed])) { + const providers = aliveProviders.get(id) + if (providers?.size === 1) { + this.ownerResolver.recordRoute(id, providers.values().next().value!) + } else { + this.ownerResolver.forgetRoute(id) + } + } + return { alive, killed } } dispose(): void { diff --git a/src/main/daemon/daemon-pty-runtime-state.ts b/src/main/daemon/daemon-pty-runtime-state.ts index 2d773d21adea..38480af1489c 100644 --- a/src/main/daemon/daemon-pty-runtime-state.ts +++ b/src/main/daemon/daemon-pty-runtime-state.ts @@ -57,7 +57,6 @@ export type DaemonPtyAdapterOptions = { historyPath?: string runtimeDir?: string packagedAppVersion?: string | null - recoveryOnly?: boolean respawn?: (reason: DaemonRespawnReason) => Promise void)> } @@ -72,15 +71,8 @@ export type DaemonIdentityChangeEvent = { current: DaemonEndpointIdentity } -export type DaemonIdleRetirementResult = - | { state: 'retiring' } - | { state: 'busy'; liveSessions: number | null; admissionReopened?: true } - | { state: 'unsupported' } - | { state: 'unverifiable' } - export abstract class DaemonPtyRuntimeState { readonly protocolVersion: number - readonly recoveryOnly: boolean protected socketPath: string protected tokenPath: string protected pidPath: string | null @@ -100,9 +92,6 @@ export abstract class DaemonPtyRuntimeState { protected packagedAppVersion: string | null protected pendingRespawnAdoptionRelease: (() => void) | null = null protected respawnAdoptionClosed = false - protected idleRetirementAdmissionClosed = false - protected idleRetirementState: 'open' | 'checking' | 'retiring' | 'unverifiable' = 'open' - protected idleRetirementPromise: Promise | null = null protected respawnPromise: Promise | null = null protected staleBundleReplacementPromise: Promise | null = null protected writeRecoveryPromise: Promise | null = null @@ -201,7 +190,6 @@ export abstract class DaemonPtyRuntimeState { constructor(opts: DaemonPtyAdapterOptions) { this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION - this.recoveryOnly = opts.recoveryOnly === true this.socketPath = opts.socketPath this.tokenPath = opts.tokenPath this.pidPath = opts.pidPath ?? null @@ -221,7 +209,7 @@ export abstract class DaemonPtyRuntimeState { }) this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null - this.respawnFn = this.recoveryOnly ? null : (opts.respawn ?? null) + this.respawnFn = opts.respawn ?? null this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null this.packagedAppVersion = opts.packagedAppVersion ?? null this.supportsCheckpoints = this.protocolVersion >= 4 diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index e7d04f5851e2..388919dd2e30 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -22,15 +22,10 @@ import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' import { resolveUnixShellPath } from '../providers/local-pty-utils' import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history' -import { assertDaemonRecoverySpawnAdmission } from './daemon-recovery-spawn-admission' import { addWslEnvKeys } from '../wsl-env' export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { async spawn(opts: PtySpawnOptions): Promise { - assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts) - if (this.idleRetirementAdmissionClosed) { - throw new Error('Terminal daemon is decommissioning') - } const spawnOpts = this.withHistoryIsolation(opts) const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId) const operation: PendingDaemonSpawnOperation = { @@ -110,10 +105,6 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { operation: PendingDaemonSpawnOperation, historyRecovery: HistoryRecoveryContext ): Promise { - assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts) - if (this.idleRetirementAdmissionClosed) { - throw new Error('Terminal daemon is decommissioning') - } if ( opts.agentSessionEnsure && this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION diff --git a/src/main/daemon/daemon-recovery-only-adapter.test.ts b/src/main/daemon/daemon-recovery-only-adapter.test.ts deleted file mode 100644 index ba3bf9be04de..000000000000 --- a/src/main/daemon/daemon-recovery-only-adapter.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { rmSync } from 'node:fs' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { DaemonPtyAdapter } from './daemon-pty-adapter' -import { - createMockSubprocess, - startDaemonAdapterHarness, - waitFor, - type DaemonAdapterHarness -} from './daemon-pty-adapter-test-harness' -import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' -import { DaemonPtyRouter } from './daemon-pty-router' - -let harness: DaemonAdapterHarness -let recovery: DaemonPtyAdapter -let subprocess: ReturnType -const spawn = vi.fn(() => subprocess) -const respawn = vi.fn(async () => {}) - -beforeEach(async () => { - spawn.mockClear() - respawn.mockClear() - subprocess = createMockSubprocess() - harness = await startDaemonAdapterHarness(spawn) - await harness.adapter.spawn({ sessionId: 'existing', cols: 80, rows: 24 }) - recovery = new DaemonPtyAdapter({ - socketPath: harness.socketPath, - tokenPath: harness.tokenPath, - recoveryOnly: true, - respawn - }) -}) - -afterEach(async () => { - recovery?.dispose() - harness.adapter.dispose() - await harness.server.shutdown() - rmSync(harness.dir, { recursive: true, force: true }) -}) - -it('reattaches and controls existing work without admitting a new process', async () => { - await expect( - recovery.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).resolves.toMatchObject({ id: 'existing', isReattach: true }) - recovery.write('existing', 'still live\n') - await waitFor(() => subprocess.write.mock.calls.length > 0) - expect(subprocess.write).toHaveBeenCalledWith('still live\n') - await expect(recovery.spawn({ sessionId: 'fresh', cols: 80, rows: 24 })).rejects.toThrow( - 'managed-stop recovery' - ) - await expect( - recovery.spawn({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 }) - ).rejects.toThrow() - expect(spawn).toHaveBeenCalledOnce() - expect(respawn).not.toHaveBeenCalled() -}) - -it('does not certify fresh admission after a confirmed native stop refusal', async () => { - await expect(recovery.requestIdleRetirement()).resolves.toEqual({ - state: 'busy', - liveSessions: 1 - }) - await expect( - recovery.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).resolves.toMatchObject({ isReattach: true }) - await expect(recovery.spawn({ cols: 80, rows: 24 })).rejects.toThrow('managed-stop recovery') - expect(spawn).toHaveBeenCalledOnce() -}) - -it('does not invoke a supplied replacement launcher after endpoint loss', async () => { - await recovery.listProcesses() - await harness.server.shutdown() - await expect( - recovery.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).rejects.toThrow() - expect(respawn).not.toHaveBeenCalled() -}) - -it('keeps recovery-only admission through routed inventory refusal', async () => { - const router = new DaemonPtyRouter({ current: recovery, legacy: [] }) - await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'busy', liveSessions: 1 }) - await expect( - router.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).resolves.toMatchObject({ isReattach: true }) - await expect(router.spawn({ cols: 80, rows: 24 })).rejects.toThrow('managed-stop recovery') - expect(spawn).toHaveBeenCalledOnce() -}) - -it('rejects legacy attach emulation before contacting the daemon', async () => { - const legacy = new DaemonPtyAdapter({ - socketPath: harness.socketPath, - tokenPath: harness.tokenPath, - protocolVersion: STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION - 1, - recoveryOnly: true - }) - try { - await expect( - legacy.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).rejects.toThrow('managed-stop recovery') - expect(spawn).toHaveBeenCalledOnce() - } finally { - legacy.dispose() - } -}) diff --git a/src/main/daemon/daemon-recovery-provider-init.ts b/src/main/daemon/daemon-recovery-provider-init.ts deleted file mode 100644 index 806c45efcf2f..000000000000 --- a/src/main/daemon/daemon-recovery-provider-init.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { rebindLocalProviderListeners } from '../ipc/pty' -import { getDaemonRuntimeDir, getDaemonHistoryDir } from './daemon-launch-paths' -import { createDaemonRecoveryProvider } from './daemon-recovery-provider' -import { installDaemonProvider } from './daemon-provider-state' - -export function initDaemonRecoveryProvider(): void { - const provider = createDaemonRecoveryProvider(getDaemonRuntimeDir(), getDaemonHistoryDir()) - installDaemonProvider(null, provider) - rebindLocalProviderListeners() -} diff --git a/src/main/daemon/daemon-recovery-provider.test.ts b/src/main/daemon/daemon-recovery-provider.test.ts deleted file mode 100644 index 17a53b370ea1..000000000000 --- a/src/main/daemon/daemon-recovery-provider.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { copyFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' -import { createDaemonRecoveryProvider } from './daemon-recovery-provider' -import { getDaemonPidPath, getDaemonTokenPath } from './daemon-spawner' -import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS } from './types' -import { - createMockSubprocess, - startDaemonAdapterHarness, - type DaemonAdapterHarness -} from './daemon-pty-adapter-test-harness' -import type { DaemonPtyRouter } from './daemon-pty-router' - -let harness: DaemonAdapterHarness -let provider: DaemonPtyRouter | undefined -beforeEach(async () => { - harness = await startDaemonAdapterHarness(() => createMockSubprocess()) - copyFileSync(harness.tokenPath, getDaemonTokenPath(harness.dir)) -}) -afterEach(async () => { - provider?.dispose() - provider = undefined - harness.adapter.dispose() - await harness.server.shutdown() - rmSync(harness.dir, { recursive: true, force: true }) -}) - -it('recovers live terminals without pruning folder or unknown workspace sessions', async () => { - await harness.adapter.spawn({ sessionId: 'folder-terminal', cols: 80, rows: 24 }) - provider = createDaemonRecoveryProvider(harness.dir, join(harness.dir, 'history')) - expect(provider.getAllAdapters().every((entry) => entry.recoveryOnly)).toBe(true) - await expect(provider.reconcileOnStartup(new Set())).resolves.toEqual({ - alive: ['folder-terminal'], - killed: [] - }) - await expect( - provider.spawn({ sessionId: 'folder-terminal', attachOnly: true, cols: 80, rows: 24 }) - ).resolves.toMatchObject({ isReattach: true }) - await expect(provider.spawn({ cols: 80, rows: 24 })).rejects.toThrow('managed-stop recovery') -}) - -it('retains unreachable legacy generations and their credentials', async () => { - const version = PREVIOUS_DAEMON_PROTOCOL_VERSIONS[0] - const tokenPath = getDaemonTokenPath(harness.dir, version) - const pidPath = getDaemonPidPath(harness.dir, version) - writeFileSync(tokenPath, 'retained-secret') - writeFileSync(pidPath, '{unreadable pid') - provider = createDaemonRecoveryProvider(harness.dir, join(harness.dir, 'history')) - const legacy = provider.getAllAdapters().find((entry) => entry.protocolVersion === version) - expect(legacy?.recoveryOnly).toBe(true) - await expect(legacy!.listSessions()).rejects.toThrow() - expect(readFileSync(tokenPath, 'utf8')).toBe('retained-secret') - expect(readFileSync(pidPath, 'utf8')).toBe('{unreadable pid') -}) diff --git a/src/main/daemon/daemon-recovery-provider.ts b/src/main/daemon/daemon-recovery-provider.ts deleted file mode 100644 index c27b24a71519..000000000000 --- a/src/main/daemon/daemon-recovery-provider.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { lstatSync } from 'node:fs' -import { DaemonPtyAdapter } from './daemon-pty-adapter' -import { DaemonPtyRouter } from './daemon-pty-router' -import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner' -import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' - -function hasEndpointEvidence(path: string): boolean { - try { - lstatSync(path) - return true - } catch (error) { - // Unreadable evidence must keep the generation represented as unverifiable. - return !(error instanceof Error && 'code' in error && error.code === 'ENOENT') - } -} - -export function createDaemonRecoveryProvider( - runtimeDir: string, - historyPath: string -): DaemonPtyRouter { - const create = (protocolVersion: number): DaemonPtyAdapter => - new DaemonPtyAdapter({ - socketPath: getDaemonSocketPath(runtimeDir, protocolVersion), - tokenPath: getDaemonTokenPath(runtimeDir, protocolVersion), - pidPath: getDaemonPidPath(runtimeDir, protocolVersion), - profileScope: runtimeDir, - runtimeDir, - historyPath, - protocolVersion, - recoveryOnly: true - }) - const legacy = PREVIOUS_DAEMON_PROTOCOL_VERSIONS.filter((version) => - [ - getDaemonPidPath(runtimeDir, version), - getDaemonTokenPath(runtimeDir, version), - ...(process.platform === 'win32' ? [] : [getDaemonSocketPath(runtimeDir, version)]) - ].some(hasEndpointEvidence) - ).map(create) - // Represent the current endpoint even when absent; missing contact is not an empty census. - return new DaemonPtyRouter({ current: create(PROTOCOL_VERSION), legacy }) -} diff --git a/src/main/daemon/daemon-recovery-spawn-admission.ts b/src/main/daemon/daemon-recovery-spawn-admission.ts deleted file mode 100644 index f7e83869eb22..000000000000 --- a/src/main/daemon/daemon-recovery-spawn-admission.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { PtySpawnOptions } from '../providers/types' -import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' - -export function assertDaemonRecoverySpawnAdmission( - recoveryOnly: boolean, - protocolVersion: number, - opts: PtySpawnOptions -): void { - if (!recoveryOnly) { - return - } - // Legacy attach emulation can create before rejecting the result. - if ( - opts.attachOnly !== true || - !opts.sessionId || - opts.agentSessionEnsure || - protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION - ) { - throw new Error('Terminal daemon admission is fenced for managed-stop recovery') - } -} diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index 651eaf36ab62..d15514ea2ae5 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -10,7 +10,6 @@ import type { DaemonSessionBackgroundRouting } from './daemon-session-background import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import type { DaemonTerminalAdmission } from './daemon-terminal-admission' -import { readDaemonHealthIdentity } from './daemon-health-identity' import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' import type { TerminalHost } from './terminal-host' import { SessionNotFoundError, type DaemonRequest } from './types' @@ -157,7 +156,7 @@ export class DaemonRequestRouter { return { health: await readCurrentProcessMacSystemResolverHealth() } case 'ptySpawnHealth': await this.options.ptySpawnHealthCheck() - return { healthy: true, ...readDaemonHealthIdentity() } + return { healthy: true } case 'shutdown': return this.shutdown(clientId, request.id, request.payload.killSessions) } diff --git a/src/main/daemon/daemon-router-retirement.test.ts b/src/main/daemon/daemon-router-retirement.test.ts deleted file mode 100644 index f47b4860ada0..000000000000 --- a/src/main/daemon/daemon-router-retirement.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { DaemonRouterRetirement } from './daemon-router-retirement' -import { createAdapter } from './daemon-pty-router-test-fixture' -import { PROTOCOL_VERSION } from './types' - -it.each(['inventory', 'protocol', 'spawn', 'live'] as const)( - 'does not reopen admission on a %s retry after partial retirement', - async (failure) => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - let adapters = [current, legacy] - const retirement = new DaemonRouterRetirement(() => adapters) - vi.mocked(legacy.requestIdleRetirement).mockResolvedValueOnce({ - state: 'busy', - liveSessions: 0 - }) - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - expect(retirement.admissionClosed).toBe(true) - if (failure === 'inventory') { - vi.mocked(current.listSessions).mockRejectedValueOnce(new Error('lost connection')) - } else if (failure === 'protocol') { - adapters = [createAdapter('old', [], undefined, 23)] - } else if (failure === 'spawn') { - retirement.spawnInFlight = 1 - } else { - adapters = [createAdapter('live', ['existing'], undefined, PROTOCOL_VERSION)] - } - expect(await retirement.requestIdleRetirement()).not.toHaveProperty('admissionReopened') - expect(retirement.admissionClosed).toBe(true) - } -) - -it('does not reopen when every native result is busy without reopening proof', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - vi.mocked(current.requestIdleRetirement).mockResolvedValue({ state: 'busy', liveSessions: 0 }) - const retirement = new DaemonRouterRetirement(() => [current]) - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - expect(retirement.admissionClosed).toBe(true) -}) - -it('keeps the fence through a lost native reply and failed retry inventory', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - vi.mocked(current.requestIdleRetirement).mockRejectedValueOnce(new Error('lost stop reply')) - const retirement = new DaemonRouterRetirement(() => [current]) - await expect(retirement.requestIdleRetirement()).rejects.toThrow('lost stop reply') - vi.mocked(current.listSessions).mockRejectedValueOnce(new Error('lost connection')) - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - expect(retirement.admissionClosed).toBe(true) -}) diff --git a/src/main/daemon/daemon-router-retirement.ts b/src/main/daemon/daemon-router-retirement.ts deleted file mode 100644 index 19420b036a95..000000000000 --- a/src/main/daemon/daemon-router-retirement.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state' -import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types' - -export class DaemonRouterRetirement { - admissionClosed = false - spawnInFlight = 0 - private retirementAttempted = false - private idleRetirementPromise: Promise | null = null - - constructor(private readonly allAdapters: () => DaemonPtyAdapter[]) {} - - async requestIdleRetirement(): Promise { - if (this.idleRetirementPromise) { - return this.idleRetirementPromise - } - this.admissionClosed = true - const request = this.finishIdleRetirementRequest().finally(() => { - if (this.idleRetirementPromise === request) { - this.idleRetirementPromise = null - } - }) - this.idleRetirementPromise = request - return request - } - - private async finishIdleRetirementRequest(): Promise { - const adapters = this.allAdapters() - if (this.spawnInFlight > 0) { - this.admissionClosed = this.retirementAttempted - return { state: 'busy', liveSessions: null } - } - if (adapters.some((adapter) => adapter.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION)) { - this.admissionClosed = this.retirementAttempted - return { state: 'unsupported' } - } - const inventories = await Promise.allSettled(adapters.map((adapter) => adapter.listSessions())) - if (inventories.some((inventory) => inventory.status === 'rejected')) { - this.admissionClosed = this.retirementAttempted - return { state: 'unverifiable' } - } - const liveSessions = inventories.reduce( - (count, inventory) => - count + - (inventory.status === 'fulfilled' - ? inventory.value.filter((session) => session.isAlive).length - : 0), - 0 - ) - if (liveSessions > 0) { - this.admissionClosed = this.retirementAttempted - return { - state: 'busy', - liveSessions, - ...(!this.retirementAttempted && !adapters.some((adapter) => adapter.recoveryOnly) - ? { admissionReopened: true as const } - : {}) - } - } - this.retirementAttempted = true - const results = await Promise.all(adapters.map((adapter) => adapter.requestIdleRetirement())) - if (results.every((result) => result.state === 'retiring')) { - return { state: 'retiring' } - } - const refusedLiveSessions = results.reduce( - (count, result) => count + (result.state === 'busy' ? (result.liveSessions ?? 0) : 0), - 0 - ) - if (refusedLiveSessions > 0) { - return { state: 'busy', liveSessions: refusedLiveSessions } - } - return { state: 'unverifiable' } - } -} diff --git a/src/main/daemon/daemon-router-session-reconciliation.ts b/src/main/daemon/daemon-router-session-reconciliation.ts deleted file mode 100644 index d58487da1067..000000000000 --- a/src/main/daemon/daemon-router-session-reconciliation.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' - -export async function reconcileDaemonRouterSessions( - adapters: readonly DaemonPtyAdapter[], - ownerResolver: DaemonSessionOwnerResolver, - validWorktreeIds: Set -): Promise<{ alive: string[]; killed: string[] }> { - const alive: string[] = [] - const killed: string[] = [] - const aliveProviders = new Map>() - for (const adapter of adapters) { - const result = await adapter.reconcileOnStartup(validWorktreeIds) - // Why: daemon startup can reconcile many restored sessions; spreading - // those arrays into push can exceed JavaScript's argument limit. - for (const id of result.alive) { - alive.push(id) - } - for (const id of result.killed) { - killed.push(id) - } - for (const id of result.alive) { - const providers = aliveProviders.get(id) ?? new Set() - providers.add(adapter) - aliveProviders.set(id, providers) - } - } - for (const id of new Set([...alive, ...killed])) { - const providers = aliveProviders.get(id) - if (providers?.size === 1) { - ownerResolver.recordRoute(id, providers.values().next().value!) - } else { - ownerResolver.forgetRoute(id) - } - } - return { alive, killed } -} From 234280888cc5706ec05013af4e025d57807bb7ce Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:03 -0700 Subject: [PATCH 12/26] Revert "feat(ssh): add pty.resumeClient and split SSH PTY process listing (#16741 T2 P5+P6) (#24414)" This reverts commit 92cb71765ed5094fefe0d6a2f29635b3cebafc61. --- ...y-notification-recovery-activation.test.ts | 326 --------------- ...-pty-notification-routing-recovery.test.ts | 281 ------------- ...h-pty-notification-routing-test-fixture.ts | 94 ----- .../ssh-pty-notification-routing.test.ts | 393 +++++++++++++++++- src/main/providers/ssh-pty-process-list.ts | 52 --- src/main/providers/ssh-pty-provider.ts | 31 +- src/main/providers/ssh-pty-spawn-env.test.ts | 25 -- .../ssh-pty-consumer-resume-session.test.ts | 213 ---------- src/main/ssh/ssh-pty-consumer-session.ts | 84 +--- .../ssh-pty-consumer-resume-client.test.ts | 131 ------ .../ssh-pty-consumer-session-adapter.test.ts | 63 +-- src/relay/ssh-pty-consumer-session-adapter.ts | 67 +-- src/shared/pty-consumer-session-contract.ts | 1 - .../pty-consumer-session-resume-only.test.ts | 239 ----------- src/shared/pty-consumer-session.ts | 31 -- 15 files changed, 430 insertions(+), 1601 deletions(-) delete mode 100644 src/main/providers/ssh-pty-notification-recovery-activation.test.ts delete mode 100644 src/main/providers/ssh-pty-notification-routing-recovery.test.ts delete mode 100644 src/main/providers/ssh-pty-notification-routing-test-fixture.ts delete mode 100644 src/main/providers/ssh-pty-process-list.ts delete mode 100644 src/main/providers/ssh-pty-spawn-env.test.ts delete mode 100644 src/main/ssh/ssh-pty-consumer-resume-session.test.ts delete mode 100644 src/relay/ssh-pty-consumer-resume-client.test.ts delete mode 100644 src/shared/pty-consumer-session-resume-only.test.ts diff --git a/src/main/providers/ssh-pty-notification-recovery-activation.test.ts b/src/main/providers/ssh-pty-notification-recovery-activation.test.ts deleted file mode 100644 index e2198f770277..000000000000 --- a/src/main/providers/ssh-pty-notification-recovery-activation.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createSubscription, sourceActivation } from './ssh-pty-notification-routing-test-fixture' - -describe('subscribeSshPtyNotifications', () => { - it('accepts non-empty recovery from the activation checkpoint', () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation( - 'pty-1', - sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) - ) - - handler('pty.data', { - id: 'pty-1', - data: 'next', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 8, - sourceLengthSu: 4 - }) - - expect(onData).not.toHaveBeenCalled() - lease.commit() - expect(onData).toHaveBeenCalledWith( - expect.objectContaining({ - data: 'next', - source: expect.objectContaining({ sourceStartSu: 4, sourceEndSu: 8 }) - }) - ) - }) - - it('routes held and later recovery frames only to the private sink until commit', () => { - const { handler, dataListeners, livePtyIds, installReceivingActivation } = createSubscription() - const onData = vi.fn() - const onRecoveryData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation( - 'pty-1', - sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 12 }) - ) - const publishSource = (data: string, sourceEndSu: number): void => { - handler('pty.data', { - id: 'pty-1', - data, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu, - sourceLengthSu: 4 - }) - } - - publishSource('held', 8) - const recoveryLease = lease.transferToRecovery(onRecoveryData) - publishSource('next', 12) - - expect(onRecoveryData.mock.calls.map(([payload]) => payload.data)).toEqual(['held', 'next']) - expect(onData).not.toHaveBeenCalled() - expect(livePtyIds).not.toContain('ssh:conn@@pty-1') - - recoveryLease.commit() - expect(onData).not.toHaveBeenCalled() - publishSource('live', 16) - - expect(onRecoveryData).toHaveBeenCalledTimes(2) - expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'live' })) - expect(livePtyIds).toContain('ssh:conn@@pty-1') - }) - - it('retires an exited private recovery when its activation commits', () => { - const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = - createSubscription() - const onData = vi.fn() - const onRecoveryData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 4 })) - handler('pty.data', { - id: 'pty-1', - data: 'held', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 4, - sourceLengthSu: 4 - }) - const recoveryLease = lease.transferToRecovery(onRecoveryData) - - handler('pty.exit', { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) - recoveryLease.commit() - handler('pty.data', { - id: 'pty-1', - data: 'late', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 8, - sourceLengthSu: 4 - }) - - expect(onRecoveryData).toHaveBeenCalledOnce() - expect(onData).not.toHaveBeenCalled() - expect(livePtyIds).not.toContain('ssh:conn@@pty-1') - expect(mux.request).toHaveBeenCalledWith( - 'pty.cancelDelivery', - expect.objectContaining({ id: 'pty-1', deliveryToken: 'token-1' }) - ) - }) - - it('retires private recovery locally and restores the exact predecessor', () => { - const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - const onRecoveryData = vi.fn() - dataListeners.add(onData) - installReceivingActivation( - 'pty-1', - sourceActivation({ deliveryToken: 'token-old', recoveryEndSu: 3 }) - ).commit() - handler('pty.data', { - id: 'pty-1', - data: 'pre', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - const replacement = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new', - checkpointSourceEndSu: 3, - recoveryEndSu: 6 - }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - replacement.transferToRecovery(onRecoveryData).retire() - handler('pty.data', { - id: 'pty-1', - data: 'old', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - expect(onRecoveryData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) - expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) - expect(mux.request).not.toHaveBeenCalled() - }) - - it('rejects a stale activation without disturbing current continuity', () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 3 })).commit() - - expect(() => - installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 1, - ownerGeneration: 4, - deliveryToken: 'token-stale', - checkpointSourceEndSu: 3, - recoveryEndSu: 3 - }) - ) - ).toThrow('ssh_source_receiving_activation_stale') - - handler('pty.data', { - id: 'pty-1', - data: 'one', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - expect(onData).toHaveBeenCalledOnce() - }) - - it('drops provisional frames and settles cancellation before rollback completes', async () => { - const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = - createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation( - 'pty-1', - sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'next', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 8, - sourceLengthSu: 4 - }) - - await expect(lease.rollback()).resolves.toBe(true) - - expect(onData).not.toHaveBeenCalled() - expect(livePtyIds).not.toContain('ssh:conn@@pty-1') - expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: 'token-1' - }) - }) - - it('restores the exact prior cursor when a replacement rolls back after frames', async () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - installReceivingActivation('pty-1', sourceActivation({ deliveryToken: 'token-old' })).commit() - handler('pty.data', { - id: 'pty-1', - data: 'pre', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - const replacement = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new', - checkpointSourceEndSu: 3, - recoveryEndSu: 3 - }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - await replacement.rollback() - handler('pty.data', { - id: 'pty-1', - data: 'old', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) - }) - - it('does not let an older lease rollback replace a newer activation', async () => { - const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const older = installReceivingActivation('pty-1', sourceActivation()) - const newer = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new' - }) - ) - - await older.rollback() - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - newer.commit() - - expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) - expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: 'token-1' - }) - expect(mux.request).not.toHaveBeenCalledWith( - 'pty.cancelDelivery', - expect.objectContaining({ deliveryToken: 'token-new' }) - ) - }) -}) diff --git a/src/main/providers/ssh-pty-notification-routing-recovery.test.ts b/src/main/providers/ssh-pty-notification-routing-recovery.test.ts deleted file mode 100644 index 2ad66f62b18a..000000000000 --- a/src/main/providers/ssh-pty-notification-routing-recovery.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing' -import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' - -type MockMux = { - onNotification: ReturnType - request: ReturnType -} - -function createSubscription() { - const mux: MockMux = { - onNotification: vi.fn(), - request: vi.fn(async () => ({ canceled: true, sentEndSu: 0, creditedEndSu: 0 })) - } - const dataListeners = new Set<(payload: { id: string; data: string }) => void>() - const replayListeners = new Set<(payload: { id: string; data: string }) => void>() - const exitListeners = new Set<(payload: { id: string; code: number }) => void>() - const livePtyIds = new Set() - const recordExit = vi.fn() - const toAppPtyId = vi.fn((id: string) => `ssh:conn@@${id}`) - const resolvePtyIncarnation = vi.fn((id: string) => `incarnation:${id}`) - - const subscription = subscribeSshPtyNotifications({ - mux: mux as never, - toAppPtyId, - dataListeners: dataListeners as never, - replayListeners: replayListeners as never, - exitListeners: exitListeners as never, - livePtyIds, - recordExit, - providerGeneration: 7, - resolvePtyIncarnation, - peekPtyIncarnation: () => undefined - }) - const handler = mux.onNotification.mock.calls[0]?.[0] as ( - method: string, - params: Record - ) => void - if (!handler) { - throw new Error('notification handler was not registered') - } - return { - handler, - mux, - toAppPtyId, - dataListeners, - replayListeners, - exitListeners, - livePtyIds, - recordExit, - resolvePtyIncarnation, - installReceivingActivation: subscription.installReceivingActivation - } -} - -function sourceActivation( - overrides: Partial = {} -): PtySourceReceivingActivation { - return Object.freeze({ - status: 'pending', - clientGeneration: 2, - ownerGeneration: 3, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - checkpointSourceEndSu: 0, - recoveryEndSu: 0, - ...overrides - }) -} - -describe('SSH PTY notification recovery routing', () => { - it('rejects a stale activation without disturbing current continuity', () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 3 })).commit() - - expect(() => - installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 1, - ownerGeneration: 4, - deliveryToken: 'token-stale', - checkpointSourceEndSu: 3, - recoveryEndSu: 3 - }) - ) - ).toThrow('ssh_source_receiving_activation_stale') - - handler('pty.data', { - id: 'pty-1', - data: 'one', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - expect(onData).toHaveBeenCalledOnce() - }) - - it('drops provisional frames and settles cancellation before rollback completes', async () => { - const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = - createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation( - 'pty-1', - sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'next', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 8, - sourceLengthSu: 4 - }) - - await expect(lease.rollback()).resolves.toBe(true) - - expect(onData).not.toHaveBeenCalled() - expect(livePtyIds).not.toContain('ssh:conn@@pty-1') - expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: 'token-1' - }) - }) - - it('restores the exact prior cursor when a replacement rolls back after frames', async () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - installReceivingActivation('pty-1', sourceActivation({ deliveryToken: 'token-old' })).commit() - handler('pty.data', { - id: 'pty-1', - data: 'pre', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - const replacement = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new', - checkpointSourceEndSu: 3, - recoveryEndSu: 3 - }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - await replacement.rollback() - handler('pty.data', { - id: 'pty-1', - data: 'old', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) - }) - - it('does not let an older lease rollback replace a newer activation', async () => { - const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const older = installReceivingActivation('pty-1', sourceActivation()) - const newer = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new' - }) - ) - - await older.rollback() - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - newer.commit() - - expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) - expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: 'token-1' - }) - expect(mux.request).not.toHaveBeenCalledWith( - 'pty.cancelDelivery', - expect.objectContaining({ deliveryToken: 'token-new' }) - ) - }) - - it('ignores PTY methods with missing ids', () => { - const { handler, toAppPtyId, dataListeners } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - - expect(() => handler('pty.data', { data: 'orphan' })).not.toThrow() - expect(toAppPtyId).not.toHaveBeenCalled() - expect(onData).not.toHaveBeenCalled() - }) - - it('leaves recovery and cancellation control methods to their dedicated handlers', () => { - const { - handler, - mux, - toAppPtyId, - dataListeners, - replayListeners, - exitListeners, - livePtyIds, - recordExit, - resolvePtyIncarnation - } = createSubscription() - const onData = vi.fn() - const onReplay = vi.fn() - const onExit = vi.fn() - dataListeners.add(onData) - replayListeners.add(onReplay) - exitListeners.add(onExit) - livePtyIds.add('ssh:conn@@unrelated') - - for (const method of [ - 'pty.recoveryData', - 'pty.recoveryComplete', - 'pty.restoreRequired', - 'pty.deliveryCanceled' - ]) { - handler(method, { - id: 'pty-1', - data: 'control', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3 - }) - } - - expect(toAppPtyId).not.toHaveBeenCalled() - expect(resolvePtyIncarnation).not.toHaveBeenCalled() - expect(recordExit).not.toHaveBeenCalled() - expect(onData).not.toHaveBeenCalled() - expect(onReplay).not.toHaveBeenCalled() - expect(onExit).not.toHaveBeenCalled() - expect(livePtyIds).toEqual(new Set(['ssh:conn@@unrelated'])) - expect(mux.request).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/providers/ssh-pty-notification-routing-test-fixture.ts b/src/main/providers/ssh-pty-notification-routing-test-fixture.ts deleted file mode 100644 index 6d86d673037f..000000000000 --- a/src/main/providers/ssh-pty-notification-routing-test-fixture.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { vi, type Mock } from 'vitest' -import { - subscribeSshPtyNotifications, - type SshPtyNotificationSubscription -} from './ssh-pty-notification-routing' -import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' - -type NotificationHandler = (method: string, params: Record) => void -type CancellationRequest = ( - method: string, - params: Record -) => Promise<{ canceled: boolean; sentEndSu: number; creditedEndSu: number }> - -type MockMux = { - onNotification: Mock<(handler: NotificationHandler) => void> - request: Mock -} - -export type SshPtyNotificationTestSubscription = { - handler: NotificationHandler - mux: MockMux - toAppPtyId: Mock<(id: string) => string> - dataListeners: Set<(payload: { id: string; data: string }) => void> - replayListeners: Set<(payload: { id: string; data: string }) => void> - exitListeners: Set<(payload: { id: string; code: number }) => void> - livePtyIds: Set - recordExit: Mock<(relayPtyId: string, incarnationId: unknown) => void> - resolvePtyIncarnation: Mock<(id: string) => string> - installReceivingActivation: SshPtyNotificationSubscription['installReceivingActivation'] -} - -export function createSubscription(): SshPtyNotificationTestSubscription { - const mux: MockMux = { - onNotification: vi.fn<(handler: NotificationHandler) => void>(), - request: vi.fn(async () => ({ - canceled: true, - sentEndSu: 0, - creditedEndSu: 0 - })) - } - const dataListeners = new Set<(payload: { id: string; data: string }) => void>() - const replayListeners = new Set<(payload: { id: string; data: string }) => void>() - const exitListeners = new Set<(payload: { id: string; code: number }) => void>() - const livePtyIds = new Set() - const recordExit = vi.fn<(relayPtyId: string, incarnationId: unknown) => void>() - const toAppPtyId = vi.fn((id: string) => `ssh:conn@@${id}`) - const resolvePtyIncarnation = vi.fn((id: string) => `incarnation:${id}`) - - const subscription = subscribeSshPtyNotifications({ - mux: mux as never, - toAppPtyId, - dataListeners: dataListeners as never, - replayListeners: replayListeners as never, - exitListeners: exitListeners as never, - livePtyIds, - recordExit, - providerGeneration: 7, - resolvePtyIncarnation, - peekPtyIncarnation: () => undefined - }) - - const handler = mux.onNotification.mock.calls[0]?.[0] - if (!handler) { - throw new Error('notification handler was not registered') - } - - return { - handler, - mux, - toAppPtyId, - dataListeners, - replayListeners, - exitListeners, - livePtyIds, - recordExit, - resolvePtyIncarnation, - installReceivingActivation: subscription.installReceivingActivation - } -} - -export function sourceActivation( - overrides: Partial = {} -): PtySourceReceivingActivation { - return Object.freeze({ - status: 'pending', - clientGeneration: 2, - ownerGeneration: 3, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - checkpointSourceEndSu: 0, - recoveryEndSu: 0, - ...overrides - }) -} diff --git a/src/main/providers/ssh-pty-notification-routing.test.ts b/src/main/providers/ssh-pty-notification-routing.test.ts index 332e8c3cdd6c..fdb6776bf369 100644 --- a/src/main/providers/ssh-pty-notification-routing.test.ts +++ b/src/main/providers/ssh-pty-notification-routing.test.ts @@ -1,5 +1,74 @@ import { describe, expect, it, vi } from 'vitest' -import { createSubscription, sourceActivation } from './ssh-pty-notification-routing-test-fixture' +import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing' +import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' + +type MockMux = { + onNotification: ReturnType + request: ReturnType +} + +function createSubscription() { + const mux: MockMux = { + onNotification: vi.fn(), + request: vi.fn(async () => ({ canceled: true, sentEndSu: 0, creditedEndSu: 0 })) + } + const dataListeners = new Set<(payload: { id: string; data: string }) => void>() + const replayListeners = new Set<(payload: { id: string; data: string }) => void>() + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const livePtyIds = new Set() + const recordExit = vi.fn() + const toAppPtyId = vi.fn((id: string) => `ssh:conn@@${id}`) + const resolvePtyIncarnation = vi.fn((id: string) => `incarnation:${id}`) + + const subscription = subscribeSshPtyNotifications({ + mux: mux as never, + toAppPtyId, + dataListeners: dataListeners as never, + replayListeners: replayListeners as never, + exitListeners: exitListeners as never, + livePtyIds, + recordExit, + providerGeneration: 7, + resolvePtyIncarnation, + peekPtyIncarnation: () => undefined + }) + + const handler = mux.onNotification.mock.calls[0]?.[0] as ( + method: string, + params: Record + ) => void + if (!handler) { + throw new Error('notification handler was not registered') + } + + return { + handler, + mux, + toAppPtyId, + dataListeners, + replayListeners, + exitListeners, + livePtyIds, + recordExit, + resolvePtyIncarnation, + installReceivingActivation: subscription.installReceivingActivation + } +} + +function sourceActivation( + overrides: Partial = {} +): PtySourceReceivingActivation { + return Object.freeze({ + status: 'pending', + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + checkpointSourceEndSu: 0, + recoveryEndSu: 0, + ...overrides + }) +} describe('subscribeSshPtyNotifications', () => { it('ignores non-PTY notifications without mapping params.id', () => { @@ -411,6 +480,328 @@ describe('subscribeSshPtyNotifications', () => { expect(mux.request).not.toHaveBeenCalled() }) + it('accepts non-empty recovery from the activation checkpoint', () => { + const { handler, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + const lease = installReceivingActivation( + 'pty-1', + sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) + ) + + handler('pty.data', { + id: 'pty-1', + data: 'next', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 8, + sourceLengthSu: 4 + }) + + expect(onData).not.toHaveBeenCalled() + lease.commit() + expect(onData).toHaveBeenCalledWith( + expect.objectContaining({ + data: 'next', + source: expect.objectContaining({ sourceStartSu: 4, sourceEndSu: 8 }) + }) + ) + }) + + it('routes held and later recovery frames only to the private sink until commit', () => { + const { handler, dataListeners, livePtyIds, installReceivingActivation } = createSubscription() + const onData = vi.fn() + const onRecoveryData = vi.fn() + dataListeners.add(onData) + const lease = installReceivingActivation( + 'pty-1', + sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 12 }) + ) + const publishSource = (data: string, sourceEndSu: number): void => { + handler('pty.data', { + id: 'pty-1', + data, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu, + sourceLengthSu: 4 + }) + } + + publishSource('held', 8) + const recoveryLease = lease.transferToRecovery(onRecoveryData) + publishSource('next', 12) + + expect(onRecoveryData.mock.calls.map(([payload]) => payload.data)).toEqual(['held', 'next']) + expect(onData).not.toHaveBeenCalled() + expect(livePtyIds).not.toContain('ssh:conn@@pty-1') + + recoveryLease.commit() + expect(onData).not.toHaveBeenCalled() + publishSource('live', 16) + + expect(onRecoveryData).toHaveBeenCalledTimes(2) + expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'live' })) + expect(livePtyIds).toContain('ssh:conn@@pty-1') + }) + + it('retires an exited private recovery when its activation commits', () => { + const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = + createSubscription() + const onData = vi.fn() + const onRecoveryData = vi.fn() + dataListeners.add(onData) + const lease = installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 4 })) + handler('pty.data', { + id: 'pty-1', + data: 'held', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 4, + sourceLengthSu: 4 + }) + const recoveryLease = lease.transferToRecovery(onRecoveryData) + + handler('pty.exit', { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) + recoveryLease.commit() + handler('pty.data', { + id: 'pty-1', + data: 'late', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 8, + sourceLengthSu: 4 + }) + + expect(onRecoveryData).toHaveBeenCalledOnce() + expect(onData).not.toHaveBeenCalled() + expect(livePtyIds).not.toContain('ssh:conn@@pty-1') + expect(mux.request).toHaveBeenCalledWith( + 'pty.cancelDelivery', + expect.objectContaining({ id: 'pty-1', deliveryToken: 'token-1' }) + ) + }) + + it('retires private recovery locally and restores the exact predecessor', () => { + const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + const onRecoveryData = vi.fn() + dataListeners.add(onData) + installReceivingActivation( + 'pty-1', + sourceActivation({ deliveryToken: 'token-old', recoveryEndSu: 3 }) + ).commit() + handler('pty.data', { + id: 'pty-1', + data: 'pre', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-old', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 3, + sourceLengthSu: 3 + }) + const replacement = installReceivingActivation( + 'pty-1', + sourceActivation({ + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'token-new', + checkpointSourceEndSu: 3, + recoveryEndSu: 6 + }) + ) + handler('pty.data', { + id: 'pty-1', + data: 'new', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-new', + clientGeneration: 3, + ownerGeneration: 4, + sourceEndSu: 6, + sourceLengthSu: 3 + }) + + replacement.transferToRecovery(onRecoveryData).retire() + handler('pty.data', { + id: 'pty-1', + data: 'old', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-old', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 6, + sourceLengthSu: 3 + }) + + expect(onRecoveryData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) + expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) + expect(mux.request).not.toHaveBeenCalled() + }) + + it('rejects a stale activation without disturbing current continuity', () => { + const { handler, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 3 })).commit() + + expect(() => + installReceivingActivation( + 'pty-1', + sourceActivation({ + clientGeneration: 1, + ownerGeneration: 4, + deliveryToken: 'token-stale', + checkpointSourceEndSu: 3, + recoveryEndSu: 3 + }) + ) + ).toThrow('ssh_source_receiving_activation_stale') + + handler('pty.data', { + id: 'pty-1', + data: 'one', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 3, + sourceLengthSu: 3 + }) + expect(onData).toHaveBeenCalledOnce() + }) + + it('drops provisional frames and settles cancellation before rollback completes', async () => { + const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = + createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + const lease = installReceivingActivation( + 'pty-1', + sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) + ) + handler('pty.data', { + id: 'pty-1', + data: 'next', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 8, + sourceLengthSu: 4 + }) + + await expect(lease.rollback()).resolves.toBe(true) + + expect(onData).not.toHaveBeenCalled() + expect(livePtyIds).not.toContain('ssh:conn@@pty-1') + expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { + id: 'pty-1', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'token-1' + }) + }) + + it('restores the exact prior cursor when a replacement rolls back after frames', async () => { + const { handler, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + installReceivingActivation('pty-1', sourceActivation({ deliveryToken: 'token-old' })).commit() + handler('pty.data', { + id: 'pty-1', + data: 'pre', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-old', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 3, + sourceLengthSu: 3 + }) + const replacement = installReceivingActivation( + 'pty-1', + sourceActivation({ + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'token-new', + checkpointSourceEndSu: 3, + recoveryEndSu: 3 + }) + ) + handler('pty.data', { + id: 'pty-1', + data: 'new', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-new', + clientGeneration: 3, + ownerGeneration: 4, + sourceEndSu: 6, + sourceLengthSu: 3 + }) + + await replacement.rollback() + handler('pty.data', { + id: 'pty-1', + data: 'old', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-old', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 6, + sourceLengthSu: 3 + }) + + expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) + }) + + it('does not let an older lease rollback replace a newer activation', async () => { + const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + const older = installReceivingActivation('pty-1', sourceActivation()) + const newer = installReceivingActivation( + 'pty-1', + sourceActivation({ + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'token-new' + }) + ) + + await older.rollback() + handler('pty.data', { + id: 'pty-1', + data: 'new', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-new', + clientGeneration: 3, + ownerGeneration: 4, + sourceEndSu: 3, + sourceLengthSu: 3 + }) + newer.commit() + + expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) + expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { + id: 'pty-1', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'token-1' + }) + expect(mux.request).not.toHaveBeenCalledWith( + 'pty.cancelDelivery', + expect.objectContaining({ deliveryToken: 'token-new' }) + ) + }) + it('ignores PTY methods with missing ids', () => { const { handler, toAppPtyId, dataListeners } = createSubscription() const onData = vi.fn() diff --git a/src/main/providers/ssh-pty-process-list.ts b/src/main/providers/ssh-pty-process-list.ts deleted file mode 100644 index 5c9c2fa82485..000000000000 --- a/src/main/providers/ssh-pty-process-list.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IPtyProvider, PtyProcessInfo } from './types' -import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' -import { mapSshPtyProcessList } from './ssh-agent-session-process-list' -import type { SshPtyProviderOutputState } from './ssh-pty-provider-output-state' - -export function createSshPtyProcessLister( - args: Pick< - Parameters[0], - 'mux' | 'connectionId' | 'livePtyIds' | 'outputState' - > -): IPtyProvider['listProcesses'] { - return (options) => - listSshPtyProcesses({ - ...args, - includeForegroundProcessEvidence: options?.includeForegroundProcessEvidence, - deadlineMs: options?.deadlineMs - }) -} - -export async function listSshPtyProcesses( - args: Readonly<{ - mux: SshChannelMultiplexer - connectionId: string - livePtyIds: Set - outputState: SshPtyProviderOutputState - includeForegroundProcessEvidence?: boolean - deadlineMs?: number - }> -): Promise { - const result = await args.mux.request( - 'pty.listProcesses', - args.includeForegroundProcessEvidence === undefined - ? undefined - : { includeForegroundProcessEvidence: args.includeForegroundProcessEvidence }, - args.deadlineMs === undefined - ? undefined - : { timeoutMs: Math.max(1, args.deadlineMs - Date.now()) } - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the relay answers pty.listProcesses with PtyProcessInfo rows; the mapper rejects unproven ownership. - const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => - toAppSshPtyId(args.connectionId, id) - ) - for (const process of processes) { - args.livePtyIds.add(process.id) - args.outputState.rememberPtyIncarnation( - toRelaySshPtyId(args.connectionId, process.id), - process.incarnationId - ) - } - return processes -} diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index dea156218a16..59088247e18b 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -1,5 +1,5 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import type { WriteSettlement } from '../../shared/pty-write-settlement' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' @@ -13,6 +13,7 @@ import type { } from './ssh-pty-provider-contract' import { SshPtyProviderOutputState } from './ssh-pty-provider-output-state' import { spawnFreshSshPty } from './ssh-agent-session-create-operation' +import { mapSshPtyProcessList } from './ssh-agent-session-process-list' import { requestSshPtyAttach, reattachSshPtySessionForSpawn, @@ -25,7 +26,6 @@ import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' import type { PtyProcessInspection } from './pty-process-inspection' import { spawnWithTerminalRuntimeRepair, type TerminalRepairHook } from './ssh-pty-spawn-repair' import { createSshPtyProviderRpcOperations } from './ssh-pty-provider-rpc-operations' -import { createSshPtyProcessLister } from './ssh-pty-process-list' // Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { @@ -38,7 +38,6 @@ export class SshPtyProvider implements IPtyProvider { private connectionId: string private livePtyIds = new Set() readonly getAppliedSize: NonNullable - readonly listProcesses: IPtyProvider['listProcesses'] private readonly agentSessionCapabilities: SshAgentSessionCapabilities private spawnExitRaces = new SshPtySpawnExitRaceTracker() private readonly outputState: SshPtyProviderOutputState @@ -102,12 +101,6 @@ export class SshPtyProvider implements IPtyProvider { this.spawnExitRaces.recordExit(relayPtyId, incarnationId) } }) - this.listProcesses = createSshPtyProcessLister({ - mux, - connectionId, - livePtyIds: this.livePtyIds, - outputState: this.outputState - }) } dispose(): void { @@ -277,6 +270,26 @@ export class SshPtyProvider implements IPtyProvider { this.livePtyIds.delete(id) } + async listProcesses(opts?: { + deadlineMs?: number + includeForegroundProcessEvidence?: boolean + }): Promise { + const result = await this.mux.request( + 'pty.listProcesses', + opts?.includeForegroundProcessEvidence === undefined + ? undefined + : { includeForegroundProcessEvidence: opts.includeForegroundProcessEvidence }, + relayTimeoutOptions(opts?.deadlineMs) + ) + const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id)) + for (const process of processes) { + this.livePtyIds.add(process.id) + const relayPtyId = this.toRelayPtyId(process.id) + this.outputState.rememberPtyIncarnation(relayPtyId, process.incarnationId) + } + return processes + } + hasPty = (id: string): boolean => this.livePtyIds.has(id) onData = (callback: SshPtyDataCallback): (() => void) => this.outputState.onData(callback) diff --git a/src/main/providers/ssh-pty-spawn-env.test.ts b/src/main/providers/ssh-pty-spawn-env.test.ts deleted file mode 100644 index 9c43d7a502b4..000000000000 --- a/src/main/providers/ssh-pty-spawn-env.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildSshPtySpawnEnv } from './ssh-pty-spawn-env' - -describe('buildSshPtySpawnEnv relay bridge', () => { - it('prepends the CLI bin dir once and publishes the Node relay bridge', () => { - const env = buildSshPtySpawnEnv({ - env: { PATH: '/home/me/.orca-relay/bin:/usr/bin' }, - remoteCliBridgeEnv: { - binDir: '/home/me/.orca-relay/bin', - relayDir: '/home/me/.orca-relay/relay-v1', - nodePath: '/usr/bin/node', - sockPath: '/home/me/.orca-relay/relay.sock' - } - }) - - expect(env).toMatchObject({ - PATH: '/home/me/.orca-relay/bin:/usr/bin', - ORCA_REMOTE_CLI_BIN_DIR: '/home/me/.orca-relay/bin', - ORCA_RELAY_DIR: '/home/me/.orca-relay/relay-v1', - ORCA_RELAY_NODE_PATH: '/usr/bin/node', - ORCA_RELAY_SOCKET_PATH: '/home/me/.orca-relay/relay.sock' - }) - expect(env).not.toHaveProperty('ORCA_RELAY_CREDENTIAL_FILE') - }) -}) diff --git a/src/main/ssh/ssh-pty-consumer-resume-session.test.ts b/src/main/ssh/ssh-pty-consumer-resume-session.test.ts deleted file mode 100644 index 60ccf6cad124..000000000000 --- a/src/main/ssh/ssh-pty-consumer-resume-session.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION } from '../../shared/pty-consumer-session' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { openSshPtyConsumerSession, resumeSshPtyConsumerSession } from './ssh-pty-consumer-session' - -function fixture() { - const controller = new AbortController() - const grant = { - protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - serverBuildId: 'build', - role: 'session-owner', - clientGeneration: 3, - ownerGeneration: 3, - ownerLease: 'owner', - resumed: true, - capabilities: { outputFlowControl: { version: 1, windowSu: 256 } } - } - const request = vi.fn().mockResolvedValue(grant) - const isDisposed = vi.fn(() => false) - const mux: Pick = { request, isDisposed } - const options = { - clientInstanceId: 'client', - expectedServerBuildId: 'build', - resume: { ownerGeneration: 2, ownerLease: 'owner' }, - outputFlowControl: { requestedWindowSu: 512 }, - signal: controller.signal, - assertAuthority: vi.fn() - } - const run = () => resumeSshPtyConsumerSession(mux, options) - return { grant, request, isDisposed, mux, options, run, controller } -} - -it('resumes through only the dedicated method and passes cancellation to transport', async () => { - const f = fixture() - await expect(f.run()).resolves.toEqual({ - resumed: true, - state: { - mode: 'negotiated', - clientInstanceId: 'client', - clientGeneration: 3, - ownerGeneration: 3, - ownerLease: 'owner', - outputFlowControl: { version: 1, windowSu: 256 } - } - }) - expect(f.request).toHaveBeenCalledExactlyOnceWith( - 'pty.resumeClient', - { - protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - clientInstanceId: 'client', - requestedRole: 'session-owner', - resume: { ownerGeneration: 2, ownerLease: 'owner' }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 512 } } - }, - { timeoutMs: 10_000, signal: f.controller.signal } - ) - expect(f.options.assertAuthority).toHaveBeenCalledTimes(2) -}) - -it.each([-32601, -32000])( - 'never falls back on host error %s, even with legacy runtime option', - async (code) => { - const f = fixture() - const error = Object.assign(new Error('host-refusal'), { code }) - f.request.mockRejectedValue(error) - const options = { ...f.options, allowSameBuildLegacyFallback: true } - const refusal = resumeSshPtyConsumerSession(f.mux, options) - // An old relay's method-not-found becomes a typed refusal; other host errors pass through. - const expected = - code === -32601 ? { message: 'pty_consumer_resume_unsupported', cause: error } : error - await expect(refusal).rejects.toMatchObject(expected) - expect(f.request).toHaveBeenCalledOnce() - expect(f.request.mock.calls[0][0]).toBe('pty.resumeClient') - } -) - -it.each([ - { ownerGeneration: 0 }, - { ownerGeneration: -1 }, - { ownerGeneration: 1.5 }, - { ownerGeneration: Number.MAX_SAFE_INTEGER + 1 }, - { ownerLease: '' }, - { ownerLease: 'x'.repeat(513) } -])('refuses invalid resume proof before RPC %j', async (proof) => { - const f = fixture() - Object.assign(f.options.resume, proof) - await expect(f.run()).rejects.toThrow('resume_required') - expect(f.request).not.toHaveBeenCalled() -}) - -it('refuses absent resume proof before RPC', async () => { - const f = fixture() - Reflect.deleteProperty(f.options, 'resume') - await expect(f.run()).rejects.toThrow('resume_required') - expect(f.request).not.toHaveBeenCalled() -}) - -it.each(['clientInstanceId', 'expectedServerBuildId'] as const)( - 'refuses missing %s before RPC', - async (field) => { - const f = fixture() - f.options[field] = '' - await expect(f.run()).rejects.toThrow('resume_required') - expect(f.request).not.toHaveBeenCalled() - } -) - -it.each([ - { ownerLease: 'different' }, - { ownerGeneration: 2 }, - { ownerGeneration: 1 }, - { resumed: false }, - { resumed: undefined }, - { ownerGeneration: 0 }, - { clientGeneration: 0 }, - { serverBuildId: 'other' }, - { role: 'observer' }, - { capabilities: undefined } -])('refuses invalid or non-resumed grant %j', async (change) => { - const f = fixture() - Object.assign(f.grant, change) - await expect(f.run()).rejects.toThrow() - expect(f.request).toHaveBeenCalledOnce() -}) - -it.each(['before', 'after'] as const)('refuses lost native authority %s RPC', async (when) => { - const f = fixture() - const revoke = () => - f.options.assertAuthority.mockImplementation(() => { - throw new Error('native-revoked') - }) - if (when === 'before') { - revoke() - } else { - f.request.mockImplementation(async () => { - await Promise.resolve() - revoke() - return f.grant - }) - } - await expect(f.run()).rejects.toThrow('native-revoked') - expect(f.request).toHaveBeenCalledTimes(when === 'before' ? 0 : 1) -}) - -it.each(['before', 'after'] as const)('refuses abort %s RPC', async (when) => { - const f = fixture() - if (when === 'before') { - f.controller.abort(new Error('aborted')) - } else { - f.request.mockImplementation(async () => { - await Promise.resolve() - f.controller.abort(new Error('aborted')) - return f.grant - }) - } - await expect(f.run()).rejects.toThrow('aborted') - expect(f.request).toHaveBeenCalledTimes(when === 'before' ? 0 : 1) -}) - -it.each(['before', 'after'] as const)('refuses disposed mux %s RPC', async (when) => { - const f = fixture() - if (when === 'before') { - f.isDisposed.mockReturnValue(true) - } else { - f.request.mockImplementation(async () => { - await Promise.resolve() - f.isDisposed.mockReturnValue(true) - return f.grant - }) - } - await expect(f.run()).rejects.toThrow('transport_closed') - expect(f.request).toHaveBeenCalledTimes(when === 'before' ? 0 : 1) -}) - -it('pins the original proof across await rather than accepting caller mutation', async () => { - const f = fixture() - f.request.mockImplementation(async () => { - await Promise.resolve() - f.options.resume.ownerLease = 'replacement' - f.options.resume.ownerGeneration = 100 - return f.grant - }) - await expect(f.run()).resolves.toMatchObject({ - resumed: true, - state: { ownerLease: 'owner', ownerGeneration: 3 } - }) - expect(f.request.mock.calls[0][1].resume).toEqual({ ownerLease: 'owner', ownerGeneration: 2 }) -}) - -it('leaves an old relay channel usable for openClient after it refuses pty.resumeClient', async () => { - const f = fixture() - f.request.mockRejectedValueOnce(Object.assign(new Error('Method not found'), { code: -32601 })) - await expect(f.run()).rejects.toThrow('pty_consumer_resume_unsupported') - expect(f.isDisposed()).toBe(false) - const { capabilities: _flow, ...plainGrant } = f.grant - f.request.mockResolvedValueOnce({ - ...plainGrant, - clientGeneration: 1, - ownerGeneration: 1, - resumed: false - }) - - const admission = await openSshPtyConsumerSession(f.mux, { - clientInstanceId: 'client', - expectedServerBuildId: 'build' - }) - - expect(admission).toMatchObject({ resumed: false, state: { mode: 'negotiated' } }) - expect(f.request.mock.calls.map(([method]) => method)).toEqual([ - 'pty.resumeClient', - 'pty.openClient' - ]) -}) diff --git a/src/main/ssh/ssh-pty-consumer-session.ts b/src/main/ssh/ssh-pty-consumer-session.ts index 3747a788762b..aa58534909bf 100644 --- a/src/main/ssh/ssh-pty-consumer-session.ts +++ b/src/main/ssh/ssh-pty-consumer-session.ts @@ -1,6 +1,5 @@ import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - PTY_CONSUMER_RESUME_CLIENT_METHOD, type PtyConsumerSessionGrant } from '../../shared/pty-consumer-session' import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' @@ -99,90 +98,13 @@ function validateGrant( } export async function openSshPtyConsumerSession( - mux: Pick, + mux: SshChannelMultiplexer, options: OpenSshPtyConsumerSessionOptions -): Promise { - return requestPtyConsumerSession(mux, options, SSH_PTY_OPEN_CLIENT_METHOD) -} - -/** Dedicated RPC: old hosts refuse without minting a replacement claim. */ -export async function resumeSshPtyConsumerSession( - mux: Pick, - options: Omit & { - resume: NonNullable - signal: AbortSignal - assertAuthority: () => void - } -): Promise { - const resume = { ...options.resume } - const signal = options.signal - const assertAuthority = options.assertAuthority - const isDisposed = mux.isDisposed.bind(mux) - const assertCurrent = () => { - signal.throwIfAborted() - assertAuthority() - if (isDisposed()) { - throw new Error('pty_consumer_resume_transport_closed') - } - } - assertCurrent() - if ( - !Number.isSafeInteger(resume.ownerGeneration) || - resume.ownerGeneration <= 0 || - typeof resume.ownerLease !== 'string' || - !resume.ownerLease || - resume.ownerLease.length > 512 || - !options.clientInstanceId || - !options.expectedServerBuildId - ) { - throw new Error('pty_consumer_resume_required') - } - let admission: SshPtyConsumerAdmission - try { - admission = await requestPtyConsumerSession( - mux, - { - clientInstanceId: options.clientInstanceId, - expectedServerBuildId: options.expectedServerBuildId, - resume, - ...(options.outputFlowControl - ? { outputFlowControl: { ...options.outputFlowControl } } - : {}), - allowSameBuildLegacyFallback: false - }, - PTY_CONSUMER_RESUME_CLIENT_METHOD, - signal - ) - } catch (error) { - // Why: a relay that predates pty.resumeClient answers method-not-found; that is a refusal of - // resume on a still-open channel, not a transport failure, so callers can fall back to openClient. - if (typeof error === 'object' && error !== null && 'code' in error && error.code === -32601) { - throw new Error('pty_consumer_resume_unsupported', { cause: error }) - } - throw error - } - assertCurrent() - if ( - !admission.resumed || - admission.state.mode !== 'negotiated' || - admission.state.ownerLease !== resume.ownerLease || - admission.state.ownerGeneration <= resume.ownerGeneration - ) { - throw new Error('pty_consumer_resume_grant_mismatch') - } - return admission -} - -async function requestPtyConsumerSession( - mux: Pick, - options: OpenSshPtyConsumerSessionOptions, - method: string, - signal?: AbortSignal ): Promise { let result: unknown try { result = await mux.request( - method, + SSH_PTY_OPEN_CLIENT_METHOD, { protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, clientInstanceId: options.clientInstanceId, @@ -199,7 +121,7 @@ async function requestPtyConsumerSession( } : {}) }, - { timeoutMs: SSH_PTY_OPEN_CLIENT_TIMEOUT_MS, ...(signal ? { signal } : {}) } + { timeoutMs: SSH_PTY_OPEN_CLIENT_TIMEOUT_MS } ) } catch (error) { const code = (error as { code?: unknown })?.code diff --git a/src/relay/ssh-pty-consumer-resume-client.test.ts b/src/relay/ssh-pty-consumer-resume-client.test.ts deleted file mode 100644 index d2b015f92141..000000000000 --- a/src/relay/ssh-pty-consumer-resume-client.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayDispatcher, type RelayClientSessionIdentity } from './dispatcher' -import { encodeJsonRpcFrame } from './protocol' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const endpointIdentity: RelayClientSessionIdentity = { - principal: 'endpoint-principal', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' -} - -function frame(id: number, method: string, overrides: Record = {}): Buffer { - return encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id, - method, - params: { - protocolVersion: 1, - clientInstanceId: 'stable-client', - requestedRole: 'session-owner', - ...overrides - } - }, - id, - 0 - ) -} - -function response(buffer: Buffer) { - return JSON.parse(buffer.subarray(13, 13 + buffer.readUInt32BE(9)).toString('utf8')) -} - -async function flushRequests(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('pty.resumeClient through RelayDispatcher', () => { - let dispatcher: RelayDispatcher | undefined - afterEach(() => dispatcher?.dispose()) - - function fixture() { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, settled) => { - writes.push(Buffer.from(data)) - settled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - return { dispatcher, adapter, writes } - } - - it('refuses absent historical ownership without minting a fresh owner or reserving the connection', async () => { - const { dispatcher, adapter, writes } = fixture() - const resume = { ownerGeneration: 1, ownerLease: 'forgotten-lease' } - dispatcher.feed(frame(1, 'pty.resumeClient', { resume })) - await flushRequests() - expect(response(writes[0]).error.message).toContain('pty_consumer_resume_owner_missing') - expect(adapter.activeSessionOwner(1)).toBeNull() - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() - dispatcher.feed(frame(2, 'pty.openClient', { resume })) - await flushRequests() - expect(response(writes[1]).result).toMatchObject({ - role: 'session-owner', - clientGeneration: 1, - ownerGeneration: 1, - resumed: false - }) - expect(adapter.activeSessionOwner(1)).not.toBeNull() - }) - - it.each([true, false])( - 'settles exact-owner resume authority only on response publication (success=%s)', - async (ok) => { - const { dispatcher, adapter, writes } = fixture() - dispatcher.feed(frame(1, 'pty.openClient')) - await flushRequests() - const grant = response(writes[0]).result - const incumbent = adapter.activeSessionOwner(1) - expect(incumbent).not.toBeNull() - const resumedWrites: Buffer[] = [] - const settlements: ((result: { ok: true } | { ok: false; error: Error }) => void)[] = [] - const successor = dispatcher.attachClient( - (data, settled) => { - resumedWrites.push(Buffer.from(data)) - settlements.push(settled) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const release = vi.spyOn(dispatcher, 'releaseDisplacedClient') - dispatcher.feedClient( - successor, - frame(2, 'pty.resumeClient', { - resume: { ownerGeneration: grant.ownerGeneration, ownerLease: grant.ownerLease } - }) - ) - await flushRequests() - expect(response(resumedWrites[0]).result).toMatchObject({ - role: 'session-owner', - resumed: true, - ownerGeneration: 2, - ownerLease: grant.ownerLease - }) - expect(adapter.activeSessionOwner(1)).toEqual(incumbent) - expect(adapter.activeSessionOwner(successor)).toBeNull() - expect(release).not.toHaveBeenCalled() - expect(() => adapter.assertOwnerPublicationSettled()).toThrow( - 'pty_consumer_owner_publication_pending' - ) - settlements[0](ok ? { ok: true } : { ok: false, error: new Error('response failed') }) - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() - if (ok) { - expect(adapter.activeSessionOwner(1)).toBeNull() - expect(adapter.activeSessionOwner(successor)).not.toBeNull() - expect(release).toHaveBeenCalledTimes(1) - } else { - expect(adapter.activeSessionOwner(1)).toEqual(incumbent) - expect(adapter.activeSessionOwner(successor)).toBeNull() - expect(release).not.toHaveBeenCalled() - } - release.mockRestore() - } - ) -}) diff --git a/src/relay/ssh-pty-consumer-session-adapter.test.ts b/src/relay/ssh-pty-consumer-session-adapter.test.ts index 262fe76a82ae..7c38799b876d 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.test.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.test.ts @@ -61,60 +61,6 @@ describe('SshPtyConsumerSessionAdapter', () => { vi.useRealTimers() }) - it('rolls back replacement ownership when response callback registration throws', async () => { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - writes.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const registration = vi.spyOn(dispatcher, 'onRequest') - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - const openClient = registration.mock.calls.find(([method]) => method === 'pty.openClient')![1] - registration.mockRestore() - dispatcher.feed(openFrame(1)) - await flushRequests() - const grant = responseResult(writes[0]) - const incumbent = adapter.activeSessionOwner(1) - expect(incumbent).not.toBeNull() - const replacement = dispatcher.attachClient(() => true, {}, endpointIdentity) - const closeIncumbent = vi.spyOn(dispatcher, 'releaseDisplacedClient') - const params = { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - resume: { ownerGeneration: grant.ownerGeneration, ownerLease: grant.ownerLease } - } - await expect( - openClient(params, { - clientId: replacement, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: () => { - throw new Error('registration failed') - } - }) - ).rejects.toThrow('registration failed') - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() - expect(adapter.activeSessionOwner(1)).toEqual(incumbent) - expect(adapter.activeSessionOwner(replacement)).toBeNull() - expect(closeIncumbent).not.toHaveBeenCalled() - await openClient(params, { - clientId: replacement, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (settle) => settle({ ok: false, error: new Error('cancel retry') }) - }) - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() - expect(adapter.activeSessionOwner(1)).toEqual(incumbent) - expect(closeIncumbent).not.toHaveBeenCalled() - closeIncumbent.mockRestore() - }) - it('does not activate owner authority until the grant write settles', async () => { const firstWrites: Buffer[] = [] const firstSettlements: ((result: { ok: true } | { ok: false; error: Error }) => void)[] = [] @@ -127,13 +73,10 @@ describe('SshPtyConsumerSessionAdapter', () => { { supportsWriteCallback: true }, endpointIdentity ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') + new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') dispatcher.feed(openFrame(1)) await flushRequests() - expect(() => adapter.assertOwnerPublicationSettled()).toThrow( - 'pty_consumer_owner_publication_pending' - ) const secondWrites: Buffer[] = [] const secondId = dispatcher.attachClient( @@ -159,7 +102,6 @@ describe('SshPtyConsumerSessionAdapter', () => { code: PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR }) firstSettlements[0]({ ok: true }) - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() }) it('rolls back owner election when the grant write fails', async () => { @@ -171,10 +113,9 @@ describe('SshPtyConsumerSessionAdapter', () => { { supportsWriteCallback: true }, endpointIdentity ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') + new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') dispatcher.feed(openFrame(1)) await flushRequests() - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() const retryWrites: Buffer[] = [] const retryId = dispatcher.attachClient( diff --git a/src/relay/ssh-pty-consumer-session-adapter.ts b/src/relay/ssh-pty-consumer-session-adapter.ts index e7b266897230..906edd47ff82 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.ts @@ -1,6 +1,5 @@ import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - PTY_CONSUMER_RESUME_CLIENT_METHOD, PtyConsumerSession, type PtyConsumerSessionAdmission, type PtyConsumerSessionGrant @@ -28,7 +27,6 @@ export class SshPtyConsumerSessionAdapter { private readonly session: PtyConsumerSession private readonly sourceCredit: SshPtySourceCreditAdapter private readonly pausedDeliveryByPty = new Map() - private readonly pendingPublications = new Set() constructor( private readonly dispatcher: RelayDispatcher, @@ -61,9 +59,6 @@ export class SshPtyConsumerSessionAdapter { dispatcher.onRequest(SSH_PTY_OPEN_CLIENT_METHOD, (params, context) => this.openClient(params, context) ) - dispatcher.onRequest(PTY_CONSUMER_RESUME_CLIENT_METHOD, (params, context) => - this.openClient(params, context, true) - ) dispatcher.onClientDetached((clientId, cause) => { const connectionKey = String(clientId) const grant = this.session.activeGrant(connectionKey) @@ -219,35 +214,9 @@ export class SshPtyConsumerSessionAdapter { return sshPtyDeliveryMode(this.session.activeGrant(String(clientId))) } - activeSessionOwner( - clientId: number - ): Readonly<{ ownerGeneration: number; ownerLease: string }> | null { - const grant = this.session.activeGrant(String(clientId)) - const ownerGeneration = grant?.ownerGeneration - if ( - grant?.role !== 'session-owner' || - typeof ownerGeneration !== 'number' || - !Number.isSafeInteger(ownerGeneration) || - !grant.ownerLease - ) { - return null - } - return Object.freeze({ - ownerGeneration, - ownerLease: grant.ownerLease - }) - } - - assertOwnerPublicationSettled(): void { - if (this.pendingPublications.size > 0) { - throw new Error('pty_consumer_owner_publication_pending') - } - } - private async openClient( rawParams: Record, - context: RequestContext, - resumeOnly = false + context: RequestContext ): Promise { const params = parseOpenClientParams(rawParams) if (params.protocolVersion !== PTY_CONSUMER_SESSION_PROTOCOL_VERSION) { @@ -256,38 +225,24 @@ export class SshPtyConsumerSessionAdapter { ) } const identity = requireIdentity(context) - const authenticate = { + const admission = this.session.admit(params, { connectionId: String(context.clientId), principal: identity.principal, authenticated: identity.authenticated, allowSessionOwner: identity.allowSessionOwner - } - const admission = resumeOnly - ? this.session.admitResumed(params, authenticate) - : this.session.admit(params, authenticate) + }) if (!context.onResponseSettled) { admission.rollbackPublication() throw new Error('SSH PTY consumer response publication fence is unavailable') } - this.pendingPublications.add(admission) - try { - context.onResponseSettled((result) => { - try { - if (!result.ok) { - admission.rollbackPublication() - return - } - admission.commitPublication() - this.closeDisplacedOwner(admission.displacedOwner) - } finally { - this.pendingPublications.delete(admission) - } - }) - } catch (error) { - this.pendingPublications.delete(admission) - admission.rollbackPublication() - throw error - } + context.onResponseSettled((result) => { + if (!result.ok) { + admission.rollbackPublication() + return + } + admission.commitPublication() + this.closeDisplacedOwner(admission.displacedOwner) + }) return admission.grant } diff --git a/src/shared/pty-consumer-session-contract.ts b/src/shared/pty-consumer-session-contract.ts index 701943afcaf0..49527f839599 100644 --- a/src/shared/pty-consumer-session-contract.ts +++ b/src/shared/pty-consumer-session-contract.ts @@ -1,5 +1,4 @@ export const PTY_CONSUMER_SESSION_PROTOCOL_VERSION = 1 -export const PTY_CONSUMER_RESUME_CLIENT_METHOD = 'pty.resumeClient' export const PTY_CONSUMER_OWNER_GRACE_MS = 30_000 export const PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR = -32041 // Why: recovery is blocked only while the incumbent owner's grant publication is still settling — a diff --git a/src/shared/pty-consumer-session-resume-only.test.ts b/src/shared/pty-consumer-session-resume-only.test.ts deleted file mode 100644 index 91f43885efbb..000000000000 --- a/src/shared/pty-consumer-session-resume-only.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR, - PtyConsumerSession, - type PtyConsumerAuthentication, - type PtyConsumerSessionHello -} from './pty-consumer-session' - -function authentication(overrides: Partial = {}) { - return { - connectionId: 'successor', - principal: 'desktop', - authenticated: true, - allowSessionOwner: true, - ...overrides - } -} - -function hello(overrides: Partial = {}): PtyConsumerSessionHello { - return { - clientInstanceId: 'client-a', - requestedRole: 'session-owner', - resume: { ownerGeneration: 1, ownerLease: 'lease-1' }, - ...overrides - } -} - -function fixture() { - let now = 0 - let lease = 0 - const createLease = vi.fn(() => `lease-${++lease}`) - const session = new PtyConsumerSession({ - serverBuildId: 'relay-build', - createLease, - ownerGraceMs: 30_000, - now: () => now - }) - return { session, createLease, advance: () => (now += 30_001) } -} - -function activate(session: PtyConsumerSession) { - const admission = session.admit( - hello({ resume: undefined }), - authentication({ connectionId: 'source' }) - ) - admission.commitPublication() - return admission -} - -describe('PtyConsumerSession resume-only admission', () => { - it('does not shorten disconnected-owner grace after a refused strict claim', () => { - let now = 0 - const session = new PtyConsumerSession({ - serverBuildId: 'relay-build', - ownerGraceMs: 30_000, - createLease: () => 'lease-1', - now: () => now - }) - activate(session) - session.close('source', 'peer-closed') - expect(() => - session.admitResumed( - hello({ resume: { ownerGeneration: 1, ownerLease: 'wrong' } }), - authentication() - ) - ).toThrow() - now = 1_000 - expect(session.admitResumed(hello(), authentication()).grant.resumed).toBe(true) - }) - it('refuses a missing owner without minting a lease or consuming generations or the connection', () => { - const { session, createLease } = fixture() - expect(() => session.admitResumed(hello(), authentication())).toThrow( - 'pty_consumer_resume_owner_missing' - ) - expect(createLease).not.toHaveBeenCalled() - expect(session.activeGrant('successor')).toBeNull() - const ordinary = session.admit(hello(), authentication()) - expect(ordinary.grant).toMatchObject({ - resumed: false, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'lease-1' - }) - expect(createLease).toHaveBeenCalledTimes(1) - }) - - it('publishes a valid resumed owner atomically without minting a new lease', () => { - const { session, createLease } = fixture() - const source = activate(session) - const successor = session.admitResumed(hello(), authentication()) - expect(successor.grant).toMatchObject({ - resumed: true, - ownerGeneration: 2, - clientGeneration: 2, - ownerLease: 'lease-1' - }) - expect(successor.displacedOwner).toEqual({ connectionId: 'source', grant: source.grant }) - expect(session.activeGrant('source')).toBe(source.grant) - expect(session.activeGrant('successor')).toBeNull() - successor.commitPublication() - expect(session.activeGrant('source')).toBeNull() - expect(session.activeGrant('successor')).toBe(successor.grant) - expect(createLease).toHaveBeenCalledTimes(1) - }) - - it('restores the incumbent on rollback and permits retry on the same connection', () => { - const { session } = fixture() - const source = activate(session) - const successor = session.admitResumed(hello(), authentication()) - successor.rollbackPublication() - successor.commitPublication() - expect(session.activeGrant('source')).toBe(source.grant) - expect(session.activeGrant('successor')).toBeNull() - const retry = session.admitResumed(hello(), authentication()) - expect(retry.grant).toMatchObject({ resumed: true, ownerGeneration: 3, ownerLease: 'lease-1' }) - retry.commitPublication() - expect(session.activeGrant('successor')).toBe(retry.grant) - }) - - it.each([ - ['lease', hello({ resume: { ownerGeneration: 1, ownerLease: 'wrong' } }), authentication()], - ['client', hello({ clientInstanceId: 'other' }), authentication()], - ['principal', hello(), authentication({ principal: 'other' })], - [ - 'generation', - hello({ resume: { ownerGeneration: 99, ownerLease: 'lease-1' } }), - authentication() - ] - ])( - 'refuses a wrong %s without disturbing the owner or consuming the connection', - (_name, request, auth) => { - const { session, createLease } = fixture() - const source = activate(session) - expect(() => session.admitResumed(request, auth)).toThrow() - expect(session.activeGrant('source')).toBe(source.grant) - const valid = session.admitResumed(hello(), authentication()) - expect(valid.grant).toMatchObject({ resumed: true, clientGeneration: 2, ownerGeneration: 2 }) - expect(createLease).toHaveBeenCalledTimes(1) - } - ) - - it('refuses expired ownership while leaving ordinary fresh-claim fallback intact', () => { - const { session, createLease, advance } = fixture() - activate(session) - session.close('source') - advance() - expect(() => session.admitResumed(hello(), authentication())).toThrow( - 'pty_consumer_resume_owner_missing' - ) - expect(createLease).toHaveBeenCalledTimes(1) - expect(session.admit(hello(), authentication()).grant).toMatchObject({ - resumed: false, - clientGeneration: 2, - ownerGeneration: 2, - ownerLease: 'lease-2' - }) - }) - - it.each([ - [ - 'missing proof', - hello({ resume: undefined }), - authentication(), - 'pty_consumer_resume_required' - ], - [ - 'subscriber', - hello({ requestedRole: 'subscriber' }), - authentication(), - 'pty_consumer_resume_required' - ], - [ - 'owner-ineligible', - hello(), - authentication({ allowSessionOwner: false }), - 'pty_consumer_resume_required' - ], - [ - 'unauthenticated', - hello(), - authentication({ authenticated: false }), - 'authentication required' - ] - ])('refuses %s without reserving the connection', (_name, request, auth, error) => { - const { session } = fixture() - activate(session) - expect(() => session.admitResumed(request, auth)).toThrow(error) - expect(session.admitResumed(hello(), authentication()).grant).toMatchObject({ - resumed: true, - clientGeneration: 2, - ownerGeneration: 2 - }) - }) - - it('preserves pending-recovery refusal and permits retry after rollback', () => { - const { session } = fixture() - const source = activate(session) - const pending = session.admitResumed(hello(), authentication({ connectionId: 'pending' })) - expect(() => session.admitResumed(hello(), authentication())).toThrow( - expect.objectContaining({ code: PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR }) - ) - expect(session.activeGrant('source')).toBe(source.grant) - pending.rollbackPublication() - const retry = session.admitResumed(hello(), authentication()) - expect(retry.grant).toMatchObject({ resumed: true, clientGeneration: 3, ownerGeneration: 3 }) - retry.commitPublication() - expect(session.activeGrant('source')).toBeNull() - }) - - it('recovers a disconnected owner within grace', () => { - const { session } = fixture() - activate(session) - session.close('source') - const successor = session.admitResumed(hello(), authentication()) - expect(successor.displacedOwner).toBeUndefined() - expect(successor.grant).toMatchObject({ - resumed: true, - ownerGeneration: 2, - ownerLease: 'lease-1' - }) - successor.commitPublication() - expect(session.activeGrant('successor')).toBe(successor.grant) - }) - - it('recovers the last durable generation only after the unpersisted successor disconnects', () => { - const { session, createLease } = fixture() - activate(session) - const first = session.admitResumed(hello(), authentication()) - first.commitPublication() - const retryAuthentication = authentication({ connectionId: 'retry' }) - expect(() => session.admitResumed(hello(), retryAuthentication)).toThrow('superseded') - session.close('successor', 'peer-closed') - const retry = session.admitResumed(hello(), retryAuthentication) - expect(retry.grant).toMatchObject({ resumed: true, ownerGeneration: 3, ownerLease: 'lease-1' }) - retry.commitPublication() - expect(createLease).toHaveBeenCalledOnce() - expect(session.activeGrant('retry')).toBe(retry.grant) - }) -}) diff --git a/src/shared/pty-consumer-session.ts b/src/shared/pty-consumer-session.ts index 4972b03d1eb4..7c5b29aacfdc 100644 --- a/src/shared/pty-consumer-session.ts +++ b/src/shared/pty-consumer-session.ts @@ -64,22 +64,6 @@ export class PtyConsumerSession { admit( hello: PtyConsumerSessionHello, authentication: PtyConsumerAuthentication - ): PtyConsumerSessionAdmission { - return this.admitInternal(hello, authentication, false) - } - - /** Migration recovery must not turn an absent historical owner into a fresh claim. */ - admitResumed( - hello: PtyConsumerSessionHello, - authentication: PtyConsumerAuthentication - ): PtyConsumerSessionAdmission { - return this.admitInternal(hello, authentication, true) - } - - private admitInternal( - hello: PtyConsumerSessionHello, - authentication: PtyConsumerAuthentication, - requireResume: boolean ): PtyConsumerSessionAdmission { validateHello(hello) assertNonEmptyString(authentication.connectionId, 'connectionId') @@ -87,14 +71,6 @@ export class PtyConsumerSession { if (!authentication.authenticated) { throw new Error('PTY consumer authentication required') } - if ( - requireResume && - (!hello.resume || - hello.requestedRole !== 'session-owner' || - !authentication.allowSessionOwner) - ) { - throw new Error('pty_consumer_resume_required') - } this.expireOwner() // Why even an identical repeat is rejected: the two responses settle their publications @@ -104,13 +80,6 @@ export class PtyConsumerSession { throw new Error('pty.openClient may be used only once per transport connection') } - if (requireResume) { - if (!this.owner) { - throw new Error('pty_consumer_resume_owner_missing') - } - // Refused recovery must not shorten an incumbent's disconnected-owner grace. - assertPtyConsumerOwnerRecovery(hello, authentication, this.owner) - } const owner = this.selectOwner(hello, authentication) const grant = Object.freeze({ protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, From bf6399cee727a08a0f7e9800ce2dc67a3a500c4b Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:03 -0700 Subject: [PATCH 13/26] Revert "feat(relay): await owned watcher and agent children on shutdown (#16741 T2 P1) (#24400)" This reverts commit 6b36e4f85bf9ea80cced1e7975cc79b80d0b5c4c. --- .../ipc/parcel-watcher-child-termination.ts | 27 +--- src/main/ipc/parcel-watcher-entry-path.ts | 8 - .../ipc/parcel-watcher-owned-children.test.ts | 120 -------------- src/main/ipc/parcel-watcher-owned-children.ts | 47 ------ .../ipc/parcel-watcher-process-entry.test.ts | 60 +++---- src/main/ipc/parcel-watcher-process-entry.ts | 2 +- .../ipc/parcel-watcher-process-supervisor.ts | 18 ++- ...parcel-watcher-supervisor-disposal.test.ts | 67 -------- .../ipc/parcel-watcher-supervisor-disposal.ts | 5 +- .../runtime-watcher-disposal-owners.test.ts | 103 ------------ .../ipc/runtime-watcher-disposal-owners.ts | 90 ----------- .../ipc/runtime-watcher-pool-shutdown.test.ts | 79 ---------- src/main/ipc/runtime-watcher-pool-state.ts | 10 +- src/main/ipc/runtime-watcher-process-pool.ts | 33 ++-- src/relay/agent-exec-disposal.test.ts | 148 ------------------ src/relay/agent-exec-handler.test.ts | 4 - src/relay/agent-exec-handler.ts | 8 - src/relay/fs-file-stream-shutdown.test.ts | 114 -------------- src/relay/fs-handler.ts | 4 - src/relay/relay-agent-process-lifetime.ts | 76 --------- src/relay/relay-filesystem-watch-registry.ts | 91 +++++------ ...lay-runtime-owned-process-shutdown.test.ts | 25 +-- src/relay/relay-runtime-services.ts | 17 +- src/relay/relay-watcher-process-pool.ts | 3 +- src/relay/relay-watcher-setup-wait.ts | 50 ------ src/relay/relay-watcher-shutdown.test.ts | 143 ----------------- src/relay/relay-watcher-shutdown.ts | 57 ------- src/relay/relay-watcher-teardown-tracker.ts | 2 +- 28 files changed, 108 insertions(+), 1303 deletions(-) delete mode 100644 src/main/ipc/parcel-watcher-owned-children.test.ts delete mode 100644 src/main/ipc/parcel-watcher-owned-children.ts delete mode 100644 src/main/ipc/parcel-watcher-supervisor-disposal.test.ts delete mode 100644 src/main/ipc/runtime-watcher-disposal-owners.test.ts delete mode 100644 src/main/ipc/runtime-watcher-disposal-owners.ts delete mode 100644 src/main/ipc/runtime-watcher-pool-shutdown.test.ts delete mode 100644 src/relay/agent-exec-disposal.test.ts delete mode 100644 src/relay/fs-file-stream-shutdown.test.ts delete mode 100644 src/relay/relay-agent-process-lifetime.ts delete mode 100644 src/relay/relay-watcher-shutdown.test.ts delete mode 100644 src/relay/relay-watcher-shutdown.ts diff --git a/src/main/ipc/parcel-watcher-child-termination.ts b/src/main/ipc/parcel-watcher-child-termination.ts index e7067dc32d63..fc498410438a 100644 --- a/src/main/ipc/parcel-watcher-child-termination.ts +++ b/src/main/ipc/parcel-watcher-child-termination.ts @@ -10,13 +10,6 @@ export const WATCHER_PROCESS_HARD_KILL_DELAY_MS = 5_000 export const WATCHER_PROCESS_EXIT_DEADLINE_MS = RUNTIME_FILE_WATCH_EXIT_DEADLINE_MS const physicalExitPromises = new WeakMap>() -const signalledChildren = new WeakSet() - -/** Sends the graceful signal once; a later awaited termination only escalates and waits. */ -export function signalWatcherChild(child: ChildProcess): void { - signalledChildren.add(child) - child.kill() -} export function registerWatcherChildPhysicalExit(child: ChildProcess): () => void { let resolveExit: () => void = () => undefined @@ -93,10 +86,6 @@ export function terminateWatcherChild(child: ChildProcess): Promise { hardKillTimer.unref?.() const exitDeadlineTimer = setTimeout(() => finish(false), WATCHER_PROCESS_EXIT_DEADLINE_MS) exitDeadlineTimer.unref?.() - if (signalledChildren.has(child)) { - return - } - signalledChildren.add(child) try { child.kill() } catch { @@ -105,10 +94,11 @@ export function terminateWatcherChild(child: ChildProcess): Promise { }) } -export function watcherChildPhysicalExit(child: ChildProcess): Promise { - return child.exitCode !== null || child.signalCode !== null - ? Promise.resolve() - : (physicalExitPromises.get(child) ?? +export function createWatcherChildTerminationFailure(child: ChildProcess): WatcherProcessFailure { + const physicalExit = + child.exitCode !== null || child.signalCode !== null + ? Promise.resolve() + : (physicalExitPromises.get(child) ?? new Promise((resolve) => { const finish = (): void => { child.removeListener('exit', finish) @@ -118,14 +108,11 @@ export function watcherChildPhysicalExit(child: ChildProcess): Promise { child.once('exit', finish) child.once('close', finish) })) -} - -export function createWatcherChildTerminationFailure(child: ChildProcess): WatcherProcessFailure { return new WatcherProcessFailure( 'file watcher process did not exit after termination deadline', 'supervisor', 'process_unavailable', - watcherChildPhysicalExit(child) + physicalExit ) } @@ -140,12 +127,10 @@ export async function terminateIdleWatcherChild( pendingUnsubscribes: Map, onFinished: (exited: boolean) => void ): Promise { - // Windows directory handles require physical exit, not merely an accepted signal. try { await requireWatcherChildTermination(child) onFinished(true) } catch (error) { - // Idle children retain capacity but cannot double-watch; the owner may remain reusable. onFinished(false) resolvePendingWatcherUnsubscribes( pendingUnsubscribes, diff --git a/src/main/ipc/parcel-watcher-entry-path.ts b/src/main/ipc/parcel-watcher-entry-path.ts index 4633739fe150..229dcdc65e28 100644 --- a/src/main/ipc/parcel-watcher-entry-path.ts +++ b/src/main/ipc/parcel-watcher-entry-path.ts @@ -4,14 +4,6 @@ import { join } from 'node:path' type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean } -export function watcherProcessEntryExists(entryPath: string): boolean { - if (existsSync(entryPath)) { - return true - } - console.error(`[parcel-watcher-process] entry not found at ${entryPath}; refusing fail-open`) - return false -} - // Why the port and not require('electron'): this module is reachable from plain-Node // fork entries, where the literal text require("electron") fails the build guard even // inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer. diff --git a/src/main/ipc/parcel-watcher-owned-children.test.ts b/src/main/ipc/parcel-watcher-owned-children.test.ts deleted file mode 100644 index cbb369cf3e28..000000000000 --- a/src/main/ipc/parcel-watcher-owned-children.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { EventEmitter } from 'node:events' -import type { ChildProcess } from 'node:child_process' -import { afterEach, expect, it, vi } from 'vitest' -import { WatcherOwnedChildren } from './parcel-watcher-owned-children' -import { - registerWatcherChildPhysicalExit, - signalWatcherChild, - WATCHER_PROCESS_EXIT_DEADLINE_MS, - WATCHER_PROCESS_HARD_KILL_DELAY_MS -} from './parcel-watcher-child-termination' - -afterEach(() => vi.useRealTimers()) - -function child() { - const exitState: { exitCode: number | null; signalCode: NodeJS.Signals | null } = { - exitCode: null, - signalCode: null - } - const events = Object.assign(new EventEmitter(), exitState, { kill: vi.fn(() => true) }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: termination reads only the exit fields, kill and events stubbed here. - const process = events as unknown as ChildProcess - const physicalExit = registerWatcherChildPhysicalExit(process) - events.on('exit', physicalExit) - events.on('close', physicalExit) - events.on('error', () => {}) - return { process, events, close: () => events.emit('close') } -} - -it('joins disposal and does not confuse disconnect/error with physical exit', async () => { - const owner = new WatcherOwnedChildren() - const c = child() - owner.track(c.process) - const logical = vi.fn() - const disposed = vi.fn() - const first = owner.disposeAndWait(logical) - expect(owner.disposeAndWait(logical)).toBe(first) - const result = first.then(disposed) - c.events.emit('disconnect') - c.events.emit('error', new Error('spawn or IPC failure')) - await Promise.resolve() - expect(disposed).not.toHaveBeenCalled() - expect(logical).toHaveBeenCalledOnce() - c.close() - await result - expect(disposed).toHaveBeenCalledOnce() -}) - -it('includes children already signaled by synchronous or retired-owner disposal', async () => { - const owner = new WatcherOwnedChildren() - const old = child() - const replacement = child() - owner.track(old.process) - old.process.kill() - owner.track(replacement.process) - const disposed = vi.fn() - const result = owner.disposeAndWait(() => {}).then(disposed) - replacement.close() - await Promise.resolve() - expect(disposed).not.toHaveBeenCalled() - old.close() - await result -}) - -it('retains a child after termination deadline failure and supports explicit retry', async () => { - vi.useFakeTimers() - const owner = new WatcherOwnedChildren() - const c = child() - owner.track(c.process) - const result = owner.disposeAndWait(() => {}).catch((error: unknown) => error) - await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS) - expect(await result).toMatchObject({ message: 'watcher_owned_children_shutdown_incomplete' }) - const completed = vi.fn() - const retry = owner.disposeAndWait(() => {}).then(completed) - await Promise.resolve() - expect(completed).not.toHaveBeenCalled() - c.close() - await retry -}) - -it('waits for other children even when logical disposal and one kill fail', async () => { - const owner = new WatcherOwnedChildren() - const failed = child() - const pending = child() - owner.track(failed.process) - owner.track(pending.process) - failed.events.kill.mockImplementationOnce(() => { - throw new Error('kill failed') - }) - const logicalFailure = new Error('logical cleanup failed') - const finished = vi.fn() - const result = owner - .disposeAndWait(() => { - throw logicalFailure - }) - .catch((error: unknown) => { - finished() - return error - }) - await Promise.resolve() - expect(finished).not.toHaveBeenCalled() - pending.close() - expect(await result).toMatchObject({ errors: [logicalFailure, expect.any(Error)] }) - const retry = owner.disposeAndWait(() => {}) - failed.close() - await retry -}) - -it('skips a second graceful signal after the supervisor sent one but still escalates', async () => { - vi.useFakeTimers() - const owner = new WatcherOwnedChildren() - const c = child() - owner.track(c.process) - const disposal = owner.disposeAndWait(() => signalWatcherChild(c.process)) - expect(c.events.kill).toHaveBeenCalledTimes(1) - expect(c.events.kill).toHaveBeenCalledWith() - await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_HARD_KILL_DELAY_MS) - expect(c.events.kill).toHaveBeenLastCalledWith('SIGKILL') - c.events.emit('exit', null, 'SIGKILL') - await expect(disposal).resolves.toBeUndefined() -}) diff --git a/src/main/ipc/parcel-watcher-owned-children.ts b/src/main/ipc/parcel-watcher-owned-children.ts deleted file mode 100644 index 7ac044ac906f..000000000000 --- a/src/main/ipc/parcel-watcher-owned-children.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' -import { - watcherChildPhysicalExit, - requireWatcherChildTermination -} from './parcel-watcher-child-termination' - -export class WatcherOwnedChildren { - private readonly children = new Set() - private disposal: Promise | null = null - - track(child: ChildProcessHandle): ChildProcessHandle { - this.children.add(child) - // Reuse launch-owned physical-exit evidence, including close without exit after spawn failure. - void watcherChildPhysicalExit(child).then(() => { - this.children.delete(child) - }) - return child - } - - disposeAndWait(disposeLogicalOwner: () => void): Promise { - if (this.disposal) { - return this.disposal - } - const failures: unknown[] = [] - try { - disposeLogicalOwner() - } catch (error) { - failures.push(error) - } - const cleanup = Promise.allSettled([...this.children].map(requireWatcherChildTermination)) - .then((results) => { - for (const result of results) { - if (result.status === 'rejected') { - failures.push(result.reason) - } - } - if (failures.length > 0) { - throw new AggregateError(failures, 'watcher_owned_children_shutdown_incomplete') - } - }) - .finally(() => { - this.disposal = null - }) - this.disposal = cleanup - return cleanup - } -} diff --git a/src/main/ipc/parcel-watcher-process-entry.test.ts b/src/main/ipc/parcel-watcher-process-entry.test.ts index 513775acb292..6ebdb5385ddd 100644 --- a/src/main/ipc/parcel-watcher-process-entry.test.ts +++ b/src/main/ipc/parcel-watcher-process-entry.test.ts @@ -86,13 +86,8 @@ describe('parcel watcher process canary', () => { await vi.advanceTimersByTimeAsync(0) expect(sendMock).toHaveBeenCalledWith( - expect.objectContaining({ op: 'subscribe-failed', id: 7 }), - expect.any(Function) + expect.objectContaining({ op: 'subscribe-failed', id: 7 }) ) - const failedSend = sendMock.mock.calls.find(([message]) => message.op === 'subscribe-failed') - expect(() => - failedSend![1](Object.assign(new Error('host disconnected'), { code: 'EPIPE' })) - ).not.toThrow() expect(watchMock).not.toHaveBeenCalledWith('/repo/.git', expect.anything(), expect.anything()) }) @@ -318,13 +313,10 @@ describe('parcel watcher process canary', () => { finishActiveCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) }) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 2 }, expect.any(Function)) + expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 2 }) expect(subscribeMock).toHaveBeenCalledTimes(2) - expect(sendMock).not.toHaveBeenCalledWith( - { op: 'subscribe-started', id: 2 }, - expect.any(Function) - ) - expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 2 }, expect.any(Function)) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribe-started', id: 2 }) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 2 }) }) it('unsubscribes a late cancel after the crawl already finished', async () => { @@ -340,16 +332,13 @@ describe('parcel watcher process canary', () => { process.emit('message', { op: 'subscribe', id: 1, dir: '/finished', opts: {} }) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).toHaveBeenCalledWith({ op: 'subscribed', id: 1 }, expect.any(Function)) + expect(sendMock).toHaveBeenCalledWith({ op: 'subscribed', id: 1 }) process.emit('message', { op: 'cancel-subscribe', id: 1 }) await vi.advanceTimersByTimeAsync(0) expect(unsubscribe).toHaveBeenCalledTimes(1) - expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }, expect.any(Function)) - expect(sendMock).not.toHaveBeenCalledWith( - { op: 'cancel-requires-restart', id: 1 }, - expect.any(Function) - ) + expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'cancel-requires-restart', id: 1 }) }) it('reports native unsubscribe rejection without acknowledging handle release', async () => { @@ -367,15 +356,12 @@ describe('parcel watcher process canary', () => { process.emit('message', { op: 'unsubscribe', id: 1 }) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).toHaveBeenCalledWith( - { - op: 'unsubscribe-failed', - id: 1, - message: 'native handle still active' - }, - expect.any(Function) - ) - expect(sendMock).not.toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }, expect.any(Function)) + expect(sendMock).toHaveBeenCalledWith({ + op: 'unsubscribe-failed', + id: 1, + message: 'native handle still active' + }) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }) }) it('asks the host to restart when an active crawl is cancelled', async () => { @@ -394,13 +380,10 @@ describe('parcel watcher process canary', () => { await vi.advanceTimersByTimeAsync(0) process.emit('message', { op: 'cancel-subscribe', id: 1 }) - expect(sendMock).toHaveBeenCalledWith( - { op: 'cancel-requires-restart', id: 1 }, - expect.any(Function) - ) + expect(sendMock).toHaveBeenCalledWith({ op: 'cancel-requires-restart', id: 1 }) finishCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) }) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 1 }, expect.any(Function)) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 1 }) }) it('still restarts after consecutive missed events once every subscription is live', async () => { @@ -498,14 +481,11 @@ describe('parcel watcher process canary', () => { callback?.(null, [{ type: 'update', path: '/repo/after-overflow.txt' }]) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).toHaveBeenCalledWith( - { - op: 'watch-error', - id: 1, - message: 'Events were dropped by the FSEvents client. File system must be re-scanned.' - }, - expect.any(Function) - ) + expect(sendMock).toHaveBeenCalledWith({ + op: 'watch-error', + id: 1, + message: 'Events were dropped by the FSEvents client. File system must be re-scanned.' + }) expect(sendMock).toHaveBeenCalledWith( { op: 'events', diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts index d2328821aded..0de05010f476 100644 --- a/src/main/ipc/parcel-watcher-process-entry.ts +++ b/src/main/ipc/parcel-watcher-process-entry.ts @@ -118,7 +118,7 @@ async function startCanary(getStableActivityRevision: () => number | null): Prom function main(): void { const send = (message: WatcherToHostMessage): void => { try { - process.send?.(message, () => undefined) + process.send?.(message) } catch { // Host is gone; the disconnect handler below exits this process. } diff --git a/src/main/ipc/parcel-watcher-process-supervisor.ts b/src/main/ipc/parcel-watcher-process-supervisor.ts index 926ff036466e..3f0a534c1986 100644 --- a/src/main/ipc/parcel-watcher-process-supervisor.ts +++ b/src/main/ipc/parcel-watcher-process-supervisor.ts @@ -1,7 +1,8 @@ import type { ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' import { restartCancelledWatcherChild } from './parcel-watcher-cancellation-restart' import { WatcherCancellationTracker } from './parcel-watcher-cancellation-tracker' -import { getWatcherProcessEntryPath, watcherProcessEntryExists } from './parcel-watcher-entry-path' +import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path' import { removeWatcherCanaryDirectory } from './parcel-watcher-canary-directory' import * as termination from './parcel-watcher-child-termination' import { launchWatcherChild } from './parcel-watcher-child-launch' @@ -38,7 +39,6 @@ import { } from './parcel-watcher-supervisor-subscribe' import { disposeWatcherSupervisor } from './parcel-watcher-supervisor-disposal' import { handleWatcherSupervisorMessage } from './parcel-watcher-supervisor-message' -import { WatcherOwnedChildren } from './parcel-watcher-owned-children' export class WatcherProcessSupervisor { private child: ChildProcess | null = null @@ -52,7 +52,6 @@ export class WatcherProcessSupervisor { private readonly pendingUnsubscribes = new Map() private readonly cancelledSubscribes = new WatcherCancellationTracker() private readonly capacityWait = new WatcherSupervisorCapacityWait() - private ownedChildren = new WatcherOwnedChildren() constructor(private readonly options: WatcherProcessSupervisorOptions = {}) {} @@ -108,7 +107,6 @@ export class WatcherProcessSupervisor { resetForTest(): void { this.dispose() - this.ownedChildren = new WatcherOwnedChildren() this.shutdownRequested = false this.terminatingChild = null this.terminationQueue.resetForTest() @@ -116,8 +114,6 @@ export class WatcherProcessSupervisor { resetWatcherChildRegistryForTest() } - disposeAndWait = (): Promise => this.ownedChildren.disposeAndWait(() => this.dispose()) - private ensureWatcherProcess( entryPath = this.options.entryPath ?? getWatcherProcessEntryPath() ): ChildProcess | null { @@ -130,7 +126,8 @@ export class WatcherProcessSupervisor { if (this.crashFuse.isOpen()) { return null } - if (!watcherProcessEntryExists(entryPath)) { + if (!existsSync(entryPath)) { + console.error(`[parcel-watcher-process] entry not found at ${entryPath}; refusing fail-open`) return null } const launched = launchWatcherChild( @@ -148,7 +145,7 @@ export class WatcherProcessSupervisor { return null } this.canaryDir = launched.canaryDir - this.child = this.ownedChildren.track(launched.child) + this.child = launched.child return launched.child } @@ -254,9 +251,14 @@ export class WatcherProcessSupervisor { } this.child = null this.terminatingChild = proc + // Why: destructive Windows cleanup must await exit to release directory handles. this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) return this.terminationQueue.track( termination.terminateIdleWatcherChild(proc, this.pendingUnsubscribes, () => { + // Why: an idle child owns zero records, so a missed exit deadline has no + // double-watch hazard; the child keeps its capacity reservation until + // physical exit, and poisoning this supervisor would permanently end + // local watching — the shared singleton has no retire-and-replace path. this.terminatingChild = null }) ) diff --git a/src/main/ipc/parcel-watcher-supervisor-disposal.test.ts b/src/main/ipc/parcel-watcher-supervisor-disposal.test.ts deleted file mode 100644 index d3aaa15770c0..000000000000 --- a/src/main/ipc/parcel-watcher-supervisor-disposal.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { acknowledgeWatcherSubscribe, FakeWatcherChild } from './parcel-watcher-process-test-child' -import { resetWatcherChildRegistryForTest } from './parcel-watcher-child-registry' - -const { forkMock } = vi.hoisted(() => ({ forkMock: vi.fn() })) -vi.mock('node:child_process', () => ({ fork: forkMock })) -vi.mock('node:fs', () => ({ - existsSync: vi.fn(() => true), - mkdtempSync: vi.fn(() => '/tmp/orca-watcher-disposal-test'), - rmSync: vi.fn() -})) -import { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor' - -const children: FakeWatcherChild[] = [] -beforeEach(() => { - resetWatcherChildRegistryForTest() - forkMock.mockImplementation(() => { - const child = new FakeWatcherChild() - children.push(child) - return child - }) -}) -afterEach(() => { - for (const child of children.splice(0)) { - child.emit('close') - } - vi.clearAllMocks() - resetWatcherChildRegistryForTest() -}) - -async function subscribe(supervisor: WatcherProcessSupervisor): Promise { - const pending = supervisor.subscribe('/repo', vi.fn(), {}) - const child = children.at(-1)! - acknowledgeWatcherSubscribe(child) - await pending - return child -} - -it('awaited supervisor disposal retains a child after prior synchronous disposal', async () => { - const supervisor = new WatcherProcessSupervisor({ useInProcessVitestFallback: false }) - const child = await subscribe(supervisor) - supervisor.dispose() - const finished = vi.fn() - const shutdown = supervisor.disposeAndWait().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - await expect(supervisor.subscribe('/another', vi.fn(), {})).rejects.toThrow() - expect(forkMock).toHaveBeenCalledOnce() - child.emit('close') - await shutdown - expect(finished).toHaveBeenCalledOnce() -}) - -it('test reset isolates old physical-exit callbacks from the new lifetime owner', async () => { - const supervisor = new WatcherProcessSupervisor({ useInProcessVitestFallback: false }) - const old = await subscribe(supervisor) - const oldShutdown = supervisor.disposeAndWait() - supervisor.resetForTest() - const current = await subscribe(supervisor) - const finished = vi.fn() - const shutdown = supervisor.disposeAndWait().then(finished) - old.emit('close') - await oldShutdown - expect(finished).not.toHaveBeenCalled() - current.emit('close') - await shutdown -}) diff --git a/src/main/ipc/parcel-watcher-supervisor-disposal.ts b/src/main/ipc/parcel-watcher-supervisor-disposal.ts index 07bc73ef3ee3..42dc918e05b2 100644 --- a/src/main/ipc/parcel-watcher-supervisor-disposal.ts +++ b/src/main/ipc/parcel-watcher-supervisor-disposal.ts @@ -9,7 +9,6 @@ import { resetPendingSubscribeAttempt, takePendingSubscribe } from './parcel-watcher-pending-subscribe' -import { signalWatcherChild } from './parcel-watcher-child-termination' import { watcherHostFailure } from './parcel-watcher-process-failure' import type { WatcherProcessSubscriptionRecord } from './parcel-watcher-process-subscription' @@ -28,8 +27,6 @@ export function disposeWatcherSupervisor( resolvePendingWatcherUnsubscribes(pendingUnsubscribes) cancelledSubscribes.completeAll() records.clear() - if (child) { - signalWatcherChild(child) - } + child?.kill() return removeWatcherCanaryDirectory(canaryDir) } diff --git a/src/main/ipc/runtime-watcher-disposal-owners.test.ts b/src/main/ipc/runtime-watcher-disposal-owners.test.ts deleted file mode 100644 index a889db510a46..000000000000 --- a/src/main/ipc/runtime-watcher-disposal-owners.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { RuntimeWatcherDisposalOwners } from './runtime-watcher-disposal-owners' - -function owner() { - return { - dispose: vi.fn(), - subscribe: vi.fn(), - disposeAndWait: vi.fn(async () => {}) - } -} - -it('retains retired cleanup and joins concurrent shutdown callers', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const retired = owner() - const pending = Promise.withResolvers() - retired.disposeAndWait.mockReturnValue(pending.promise) - owners.retire(retired) - const shutdown = owners.disposeAndWait(() => {}) - expect(owners.disposeAndWait(() => {})).toBe(shutdown) - const finished = vi.fn() - const result = shutdown.then(finished) - await Promise.resolve() - expect(finished).not.toHaveBeenCalled() - expect(retired.disposeAndWait).toHaveBeenCalledOnce() - pending.resolve() - await result - await owners.disposeAndWait(() => {}) - expect(retired.disposeAndWait).toHaveBeenCalledOnce() -}) - -it('does not retry a newly failed attempt until a later explicit shutdown call', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const failed = owner() - const sibling = owner() - const pending = Promise.withResolvers() - const failure = new Error('child still live') - failed.disposeAndWait.mockRejectedValueOnce(failure) - sibling.disposeAndWait.mockReturnValue(pending.promise) - const finished = vi.fn() - const shutdown = owners.disposeAndWait(() => { - owners.retire(failed) - owners.retire(sibling) - }) - const result = shutdown.catch((error: unknown) => { - finished() - return error - }) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - expect(owners.disposeAndWait(() => {})).toBe(shutdown) - expect(failed.disposeAndWait).toHaveBeenCalledOnce() - pending.resolve() - expect(await result).toMatchObject({ errors: [failure] }) - await owners.disposeAndWait(() => {}) - expect(failed.disposeAndWait).toHaveBeenCalledTimes(2) - expect(sibling.disposeAndWait).toHaveBeenCalledOnce() -}) - -it('retains synchronous failures and attempts sibling owners', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const failed = owner() - const sibling = owner() - failed.disposeAndWait.mockImplementationOnce(() => { - throw new Error('sync failure') - }) - await expect( - owners.disposeAndWait(() => { - owners.retire(failed) - owners.retire(sibling) - }) - ).rejects.toThrow('watcher_pool_shutdown_incomplete') - expect(sibling.disposeAndWait).toHaveBeenCalledOnce() - await owners.disposeAndWait(() => {}) - expect(failed.disposeAndWait).toHaveBeenCalledTimes(2) -}) - -it('publishes the retained attempt before a synchronous disposal callback reenters', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const child = owner() - const pending = Promise.withResolvers() - let nested: Promise | undefined - child.disposeAndWait.mockImplementation(() => { - owners.retire(child) - nested = owners.disposeAndWait(() => {}) - return pending.promise - }) - const shutdown = owners.disposeAndWait(() => owners.retire(child)) - expect(nested).toBe(shutdown) - expect(child.disposeAndWait).toHaveBeenCalledOnce() - pending.resolve() - await shutdown -}) - -it('refuses to acknowledge a supervisor without a physical-disposal API', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const legacy = { dispose: vi.fn(), subscribe: vi.fn() } - await expect(owners.disposeAndWait(() => owners.retire(legacy))).rejects.toMatchObject({ - errors: [ - expect.objectContaining({ message: 'watcher_supervisor_awaited_disposal_unavailable' }) - ] - }) - expect(legacy.dispose).toHaveBeenCalledOnce() -}) diff --git a/src/main/ipc/runtime-watcher-disposal-owners.ts b/src/main/ipc/runtime-watcher-disposal-owners.ts deleted file mode 100644 index e88c741844fb..000000000000 --- a/src/main/ipc/runtime-watcher-disposal-owners.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { RuntimeWatcherPoolSupervisor } from './runtime-watcher-pool-state' - -type Attempt = { pending: Promise | null; failure?: unknown } - -type Completion = { promise: Promise; resolve: () => void; reject: (error: unknown) => void } - -// Why not Promise.withResolvers: the relay bundles this pool and still targets Node 18 hosts. -function createCompletion(): Completion { - let resolve!: () => void - let reject!: (error: unknown) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - -export class RuntimeWatcherDisposalOwners { - private readonly retained = new Map() - private shutdown: Promise | null = null - - retire(owner: RuntimeWatcherPoolSupervisor): void { - if (!this.retained.has(owner)) { - this.start(owner) - } - } - - disposeAndWait(disposePool: () => void): Promise { - if (this.shutdown) { - return this.shutdown - } - const retry = [...this.retained].filter(([, attempt]) => !attempt.pending) - const completion = createCompletion() - this.shutdown = completion.promise - const failures: unknown[] = [] - try { - disposePool() - } catch (error) { - failures.push(error) - } - for (const [owner, attempt] of retry) { - if (this.retained.get(owner) === attempt) { - this.start(owner) - } - } - const pending = [...this.retained.values()].map( - (attempt) => attempt.pending ?? Promise.reject(attempt.failure) - ) - void Promise.allSettled(pending).then((results) => { - for (const result of results) { - if (result.status === 'rejected') { - failures.push(result.reason) - } - } - this.shutdown = null - if (failures.length > 0) { - completion.reject(new AggregateError(failures, 'watcher_pool_shutdown_incomplete')) - } else { - completion.resolve() - } - }) - return completion.promise - } - - private start(owner: RuntimeWatcherPoolSupervisor): void { - const completion = createCompletion() - const attempt: Attempt = { pending: completion.promise } - this.retained.set(owner, attempt) - void completion.promise.catch(() => {}) - const failed = (error: unknown): void => { - attempt.pending = null - attempt.failure = error - completion.reject(error) - } - try { - if (!owner.disposeAndWait) { - owner.dispose() - throw new Error('watcher_supervisor_awaited_disposal_unavailable') - } - void owner.disposeAndWait().then(() => { - if (this.retained.get(owner) === attempt) { - this.retained.delete(owner) - } - completion.resolve() - }, failed) - } catch (error) { - failed(error) - } - } -} diff --git a/src/main/ipc/runtime-watcher-pool-shutdown.test.ts b/src/main/ipc/runtime-watcher-pool-shutdown.test.ts deleted file mode 100644 index 1f37f3540c5d..000000000000 --- a/src/main/ipc/runtime-watcher-pool-shutdown.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { RuntimeWatcherProcessPool } from './runtime-watcher-process-pool' -import type { WatcherProcessHooks } from './parcel-watcher-process-subscription' -import { WatcherProcessFailure } from './parcel-watcher-process-failure' - -function supervisor() { - const pending = Promise.withResolvers() - let hooks: WatcherProcessHooks | undefined - return { - pending, - dispose: vi.fn(), - disposeAndWait: vi.fn(() => pending.promise), - subscribe: vi.fn( - async ( - _dir: string, - _callback: unknown, - _options: unknown, - options?: WatcherProcessHooks - ) => { - hooks = options - return { unsubscribe: vi.fn(async () => {}) } - } - ), - fail: () => - hooks?.onTerminalError?.( - new WatcherProcessFailure('failed', 'supervisor', 'process_unavailable') - ) - } -} - -it('awaits retired and replacement supervisors after logical retirement removed the old slot', async () => { - const old = supervisor() - const current = supervisor() - const create = vi.fn().mockReturnValueOnce(old).mockReturnValueOnce(current) - const pool = new RuntimeWatcherProcessPool({ createSupervisor: create }) - await pool.subscribe('/first', vi.fn(), {}) - old.fail() - await new Promise((resolve) => setImmediate(resolve)) - expect(old.disposeAndWait).toHaveBeenCalledOnce() - await pool.subscribe('/second', vi.fn(), {}) - const finished = vi.fn() - const shutdown = pool.disposeAndWait() - expect(pool.disposeAndWait()).toBe(shutdown) - const result = shutdown.then(finished) - current.pending.resolve() - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - await expect(pool.subscribe('/third', vi.fn(), {})).rejects.toThrow('disposed') - expect(create).toHaveBeenCalledTimes(2) - old.pending.resolve() - await result -}) - -it('retains failure after synchronous pool disposal and retries it explicitly', async () => { - const child = supervisor() - const failure = new Error('child still running') - child.disposeAndWait.mockRejectedValueOnce(failure) - const pool = new RuntimeWatcherProcessPool({ createSupervisor: () => child }) - await pool.subscribe('/first', vi.fn(), {}) - const shutdown = pool.disposeAndWait() - await expect(shutdown).rejects.toMatchObject({ errors: [failure] }) - const retry = pool.disposeAndWait() - expect(child.disposeAndWait).toHaveBeenCalledTimes(2) - child.pending.resolve() - await retry -}) - -it('does not duplicate termination when a queued retirement races with synchronous disposal', async () => { - const child = supervisor() - const pool = new RuntimeWatcherProcessPool({ createSupervisor: () => child }) - await pool.subscribe('/first', vi.fn(), {}) - child.fail() - pool.dispose() - const shutdown = pool.disposeAndWait() - await new Promise((resolve) => setImmediate(resolve)) - expect(child.disposeAndWait).toHaveBeenCalledOnce() - child.pending.resolve() - await shutdown -}) diff --git a/src/main/ipc/runtime-watcher-pool-state.ts b/src/main/ipc/runtime-watcher-pool-state.ts index a99c9b938958..e6c016b98449 100644 --- a/src/main/ipc/runtime-watcher-pool-state.ts +++ b/src/main/ipc/runtime-watcher-pool-state.ts @@ -1,7 +1,6 @@ import type { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor' -export type RuntimeWatcherPoolSupervisor = Pick & - Partial> +export type RuntimeWatcherPoolSupervisor = Pick export type RuntimeWatcherPoolSlot = { supervisor: RuntimeWatcherPoolSupervisor @@ -11,13 +10,6 @@ export type RuntimeWatcherPoolSlot = { disposed: boolean } -export function activeWatcherSlots( - slots: ReadonlySet, - isolated: boolean -): RuntimeWatcherPoolSlot[] { - return [...slots].filter((slot) => slot.isolated === isolated && !slot.retired) -} - export type RuntimeWatcherPoolAssignment = { slot: RuntimeWatcherPoolSlot leases: number diff --git a/src/main/ipc/runtime-watcher-process-pool.ts b/src/main/ipc/runtime-watcher-process-pool.ts index 852fd5abdf41..4bafc38208df 100644 --- a/src/main/ipc/runtime-watcher-process-pool.ts +++ b/src/main/ipc/runtime-watcher-process-pool.ts @@ -7,17 +7,15 @@ import type { WatcherProcessSubscription } from './parcel-watcher-process-subscription' import { RuntimeWatcherPendingAssignment } from './runtime-watcher-pending-assignment' -import { RuntimeWatcherDisposalOwners } from './runtime-watcher-disposal-owners' import { RuntimeWatcherPoolLifecycle } from './runtime-watcher-pool-lifecycle' import { RuntimeWatcherPredecessorBarriers } from './runtime-watcher-predecessor-barriers' import { RuntimeWatcherQuarantineQueue } from './runtime-watcher-quarantine-queue' import { handleRuntimeWatcherSubscriptionFailure } from './runtime-watcher-subscription-failure' -import { - activeWatcherSlots, - type RuntimeWatcherPoolAssignment, - type RuntimeWatcherPoolSlot, - type RuntimeWatcherPoolSupervisor, - type RuntimeWatcherProcessPoolOptions +import type { + RuntimeWatcherPoolAssignment, + RuntimeWatcherPoolSlot, + RuntimeWatcherPoolSupervisor, + RuntimeWatcherProcessPoolOptions } from './runtime-watcher-pool-state' export type { RuntimeWatcherProcessPoolOptions } from './runtime-watcher-pool-state' @@ -41,7 +39,6 @@ export class RuntimeWatcherProcessPool { RuntimeWatcherPendingAssignment >() private readonly lifecycle = new RuntimeWatcherPoolLifecycle() - private disposalOwners = new RuntimeWatcherDisposalOwners() private readonly predecessorBarriers = new RuntimeWatcherPredecessorBarriers() private readonly quarantineQueue: RuntimeWatcherQuarantineQueue @@ -145,12 +142,9 @@ export class RuntimeWatcherProcessPool { resetForTest(): void { this.dispose() - this.disposalOwners = new RuntimeWatcherDisposalOwners() this.lifecycle.reset() } - disposeAndWait = (): Promise => this.disposalOwners.disposeAndWait(() => this.dispose()) - forgetRoot(dir: string): void { // Physical subscriptions release their assignment through unsubscribe or // terminal callbacks. This clears fault history after setup gives up. @@ -227,7 +221,9 @@ export class RuntimeWatcherProcessPool { } private sharedSlot(): RuntimeWatcherPoolSlot { - const sharedSlots = activeWatcherSlots(this.activeSlots, false) + const sharedSlots = Array.from(this.activeSlots).filter( + (slot) => !slot.isolated && !slot.retired + ) if (sharedSlots.length < this.maxSharedSupervisors) { return this.createSlot(false) } @@ -237,7 +233,9 @@ export class RuntimeWatcherProcessPool { } private quarantineSlot(dir: string): RuntimeWatcherPoolSlot | Promise { - const quarantineSlots = activeWatcherSlots(this.activeSlots, true) + const quarantineSlots = Array.from(this.activeSlots).filter( + (slot) => slot.isolated && !slot.retired + ) if (quarantineSlots.length < this.maxQuarantineSupervisors) { return this.createSlot(true) } @@ -275,11 +273,7 @@ export class RuntimeWatcherProcessPool { slot.roots.clear() // Why: failAllSubscriptions is still iterating callbacks; defer disposal // so every logical root receives the supervisor failure first. - const owners = this.disposalOwners queueMicrotask(() => { - if (this.disposalOwners !== owners) { - return - } this.disposeSlot(slot) this.drainQuarantineWaiters() }) @@ -314,7 +308,8 @@ export class RuntimeWatcherProcessPool { private drainQuarantineWaiters(): void { while ( this.quarantineQueue.length > 0 && - activeWatcherSlots(this.activeSlots, true).length < this.maxQuarantineSupervisors + Array.from(this.activeSlots).filter((slot) => slot.isolated && !slot.retired).length < + this.maxQuarantineSupervisors ) { this.quarantineQueue.grantNext(this.createSlot(true)) } @@ -325,7 +320,7 @@ export class RuntimeWatcherProcessPool { return } slot.disposed = true - this.disposalOwners.retire(slot.supervisor) + slot.supervisor.dispose() this.allSlots.delete(slot) } } diff --git a/src/relay/agent-exec-disposal.test.ts b/src/relay/agent-exec-disposal.test.ts deleted file mode 100644 index 6e770ff5b9e2..000000000000 --- a/src/relay/agent-exec-disposal.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { execFile } from 'node:child_process' -import type * as ChildProcess from 'node:child_process' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { AgentExecHandler } from './agent-exec-handler' -import { RELAY_AGENT_CLOSE_DEADLINE_MS } from './relay-agent-process-lifetime' -import { createFakeChild, requestContext } from './agent-exec-handler-test-harness' -import type { MethodHandler, RelayDispatcher } from './dispatcher' - -// Why an untyped mock: the fake child stubs only what AgentExecHandler reads from a ChildProcess. -const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) -vi.mock('child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawn: (...args: unknown[]) => spawnMock(...args), - execFile: vi.fn() -})) - -function fixture() { - const methods = new Map() - const handler = new AgentExecHandler( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler only registers request methods. - { - onRequest: (name: string, method: MethodHandler) => methods.set(name, method) - } as unknown as RelayDispatcher - ) - const exec = (params: Record = {}) => - methods.get('agent.execNonInteractive')!( - { binary: 'agent', timeoutMs: 1000, ...params }, - requestContext() - ) - return { handler, exec } -} - -beforeEach(() => { - vi.useFakeTimers() - spawnMock.mockReset() - vi.mocked(execFile).mockReset() -}) -afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() -}) - -it('waits for every child close, including commands without a cwd and replaced lanes', async () => { - const f = fixture() - const children = [createFakeChild(), createFakeChild(), createFakeChild()] - for (const child of children) { - spawnMock.mockReturnValueOnce(child) - } - const requests = [f.exec(), f.exec({ cwd: '/repo' }), f.exec({ cwd: '/repo' })] - let finished = false - const disposal = f.handler.dispose().then(() => { - finished = true - }) - await Promise.resolve() - expect(finished).toBe(false) - if (process.platform === 'win32') { - expect(execFile).toHaveBeenCalledWith( - 'taskkill', - ['/pid', '12345', '/T', '/F'], - expect.any(Function) - ) - } else { - for (const child of children) { - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - } - } - children[0].emit('close', null) - children[2].emit('close', null) - await Promise.resolve() - expect(finished).toBe(false) - children[1].emit('close', null) - await disposal - expect(finished).toBe(true) - await Promise.all(requests) -}) - -it('refuses execution once disposal begins and after it completes', async () => { - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec() - const disposal = f.handler.dispose() - await expect(f.exec()).rejects.toThrow() - expect(spawnMock).toHaveBeenCalledTimes(1) - child.emit('close', null) - await Promise.all([request, disposal]) - await expect(f.exec()).rejects.toThrow() - expect(spawnMock).toHaveBeenCalledTimes(1) -}) - -it('retains timed-out children until close rather than treating RPC settlement as exit', async () => { - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec({ cwd: '/repo' }) - await vi.advanceTimersByTimeAsync(1000) - await expect(request).resolves.toMatchObject({ timedOut: true }) - let finished = false - const disposal = f.handler.dispose().then(() => { - finished = true - }) - await Promise.resolve() - expect(finished).toBe(false) - child.emit('error', new Error('late child error')) - await Promise.resolve() - expect(finished).toBe(false) - child.emit('close', null) - await disposal - expect(finished).toBe(true) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) -}) - -it('resolves disposal with no children or only already-closed children', async () => { - await fixture().handler.dispose() - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec() - child.emit('close', 0) - await request - await f.handler.dispose() - expect(child.kill).not.toHaveBeenCalled() -}) - -it('rejects within the close deadline and re-checks without re-killing on retry', async () => { - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec({ timeoutMs: 60_000 }) - const signals = () => - process.platform === 'win32' - ? vi.mocked(execFile).mock.calls.length - : child.kill.mock.calls.length - const first = f.handler.dispose() - const firstOutcome = first.then( - () => 'resolved', - (error: Error) => error.message - ) - expect(signals()).toBe(1) - await vi.advanceTimersByTimeAsync(RELAY_AGENT_CLOSE_DEADLINE_MS) - await expect(firstOutcome).resolves.toBe('relay_agent_execution_shutdown_incomplete') - const retry = f.handler.dispose() - expect(signals()).toBe(1) - child.emit('close', null) - await expect(retry).resolves.toBeUndefined() - await request -}) diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts index 1cb2820b1ec8..87a2ef0403b4 100644 --- a/src/relay/agent-exec-handler.test.ts +++ b/src/relay/agent-exec-handler.test.ts @@ -523,10 +523,6 @@ describe('AgentExecHandler', () => { } expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) - // Shutdown keeps tracking the child until it physically closes. - expect(child.listenerCount('error')).toBe(1) - expect(child.listenerCount('close')).toBe(1) - child.emit('close', null) expect(child.listenerCount('error')).toBe(0) expect(child.listenerCount('close')).toBe(0) } finally { diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts index 4314dbbc6a7c..152e8bd72fbe 100644 --- a/src/relay/agent-exec-handler.ts +++ b/src/relay/agent-exec-handler.ts @@ -6,7 +6,6 @@ import type { RelayDispatcher, RequestContext } from './dispatcher' import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard' import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env' import { terminateRelaySubprocessTree } from './subprocess-tree-termination' -import { RelayAgentProcessLifetime } from './relay-agent-process-lifetime' const DEFAULT_TIMEOUT_MS = 60_000 const MAX_TIMEOUT_MS = 5 * 60 * 1000 @@ -110,7 +109,6 @@ type ExecResult = { * and a clean exit code instead of an interactive session. */ export class AgentExecHandler { - private readonly processLifetime = new RelayAgentProcessLifetime() // Why: commit-message and PR-field generation can run together for one cwd; // operation lanes let cancel target only the user-visible job that stopped. private inFlightByLane = new Map() @@ -126,10 +124,6 @@ export class AgentExecHandler { dispatcher.onRequest('agent.cancelExec', (p) => this.cancel(p as CancelParams)) } - dispose(): Promise { - return this.processLifetime.dispose() - } - private async cancel(params: CancelParams): Promise<{ canceled: boolean }> { const cwd = typeof params.cwd === 'string' ? params.cwd : '' const entry = this.inFlightByLane.get(this.laneKey(cwd, params.operation)) @@ -141,7 +135,6 @@ export class AgentExecHandler { } private async exec(params: ExecParams, context?: RequestContext): Promise { - this.processLifetime.assertAdmission() const binary = typeof params.binary === 'string' ? params.binary : '' if (!binary) { throw new Error('agent.execNonInteractive: binary is required') @@ -195,7 +188,6 @@ export class AgentExecHandler { return } - this.processLifetime.track(child) let stdout = '' let stderr = '' let stdoutBytes = 0 diff --git a/src/relay/fs-file-stream-shutdown.test.ts b/src/relay/fs-file-stream-shutdown.test.ts deleted file mode 100644 index c26fb73b2c4d..000000000000 --- a/src/relay/fs-file-stream-shutdown.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { RelayDispatcher, RequestContext } from './dispatcher' -import { readRelayFileStreamMetadata } from './fs-handler-file-read' -import { RelayStreamRegistry } from './fs-stream-registry' -import { STREAM_CHUNK_SIZE } from './protocol' - -let directory: string -let filePath: string -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-file-stream-shutdown-')) - filePath = join(directory, 'sample.png') - await writeFile(filePath, Buffer.alloc(12, 42)) -}) -afterEach(async () => { - vi.restoreAllMocks() - await rm(directory, { recursive: true, force: true }) -}) - -function fixture() { - const registry = new RelayStreamRegistry() - const bulk = vi.fn(async () => {}) - const terminal = vi.fn((_id, _method, _params, settled) => { - settled({ ok: true }) - return true - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the file stream reads only these two dispatcher methods. - const dispatcher = { notifyBulk: bulk, tryNotifyClient: terminal } as unknown as RelayDispatcher - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stream reads only clientId and isStale from its context. - const context = { clientId: 1, isStale: () => false } as RequestContext - const start = (paceWithAcks = false) => - readRelayFileStreamMetadata(filePath, dispatcher, registry, context, { - clientId: 1, - paceWithAcks - }) - return { registry, bulk, terminal, start } -} - -it('waits for a scheduled pump and permanently fences later metadata requests', async () => { - const f = fixture() - const scheduled: (() => void)[] = [] - // A real, already-cleared handle satisfies the return type without scheduling anything. - const handle = setImmediate(() => {}) - clearImmediate(handle) - const schedule = vi.spyOn(globalThis, 'setImmediate').mockImplementation((callback) => { - scheduled.push(callback) - return handle - }) - await f.start() - schedule.mockRestore() - const finished = vi.fn() - const drain = f.registry.disposeAll().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - await expect(f.start()).rejects.toThrow('relay_file_stream_shutdown_fenced') - scheduled.forEach((run) => run()) - await drain - expect(f.bulk).not.toHaveBeenCalled() - expect(f.terminal).not.toHaveBeenCalled() -}) - -it.each(['resolve', 'reject'] as const)( - 'waits for an in-flight final chunk to %s without a terminal frame', - async (outcome) => { - const f = fixture() - const pending = Promise.withResolvers() - f.bulk.mockReturnValue(pending.promise) - await f.start() - await vi.waitFor(() => expect(f.bulk).toHaveBeenCalledOnce()) - const finished = vi.fn() - const drain = f.registry.disposeAll().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - if (outcome === 'resolve') { - pending.resolve() - } else { - pending.reject(new Error('transport closed')) - } - await drain - expect(f.terminal).not.toHaveBeenCalled() - } -) - -it('wakes an ACK-parked producer during shutdown', async () => { - const f = fixture() - await writeFile(filePath, Buffer.alloc(STREAM_CHUNK_SIZE * 6, 42)) - await f.start(true) - await vi.waitFor(() => expect(f.bulk).toHaveBeenCalledTimes(4)) - await f.registry.disposeAll() - expect(f.bulk).toHaveBeenCalledTimes(4) - expect(f.terminal).not.toHaveBeenCalled() -}) - -it('retains a binary-probe handle when its close fails and retries shutdown cleanup', async () => { - const f = fixture() - filePath = join(directory, 'sample.txt') - await writeFile(filePath, 'text content') - const failure = new Error('probe close failed') - const release = f.registry.releaseUnregisteredHandle.bind(f.registry) - vi.spyOn(f.registry, 'releaseUnregisteredHandle').mockImplementationOnce((handle) => { - vi.spyOn(handle, 'close').mockRejectedValueOnce(failure) - return release(handle) - }) - try { - await expect(f.start()).rejects.toBe(failure) - expect(f.registry.size()).toBe(1) - await f.registry.disposeAll() - expect(f.registry.size()).toBe(0) - } finally { - await f.registry.disposeAll() - } -}) diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 87ef3444d83a..39b229847663 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -275,8 +275,4 @@ export class FsHandler { disposeFileStreams(): Promise { return this.streamRegistry.disposeAll() } - - disposeWatchers(): Promise { - return this.watchRegistry.disposeAndWait() - } } diff --git a/src/relay/relay-agent-process-lifetime.ts b/src/relay/relay-agent-process-lifetime.ts deleted file mode 100644 index 9e290d3e9524..000000000000 --- a/src/relay/relay-agent-process-lifetime.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { terminateRelaySubprocessTree } from './subprocess-tree-termination' - -type Child = Parameters[0] - -// Why 10s: the same physical-exit deadline the relay's watcher children get after a kill. -export const RELAY_AGENT_CLOSE_DEADLINE_MS = 10_000 - -/** Request timeout/cancellation is not evidence that its host child has closed. */ -export class RelayAgentProcessLifetime { - private fenced = false - private readonly children = new Map>() - // Why: a retry re-checks close instead of re-killing a tree whose pid may already be reused. - private readonly signalled = new WeakSet() - private disposal: Promise | null = null - - constructor(private readonly closeDeadlineMs = RELAY_AGENT_CLOSE_DEADLINE_MS) {} - - assertAdmission(): void { - if (this.fenced) { - throw new Error('relay_agent_execution_shutdown_fenced') - } - } - - track(child: Child): void { - this.assertAdmission() - // Why: no Promise.withResolvers — the relay bundle still targets Node 18 hosts. - let resolveClosed!: () => void - const closed = new Promise((resolve) => { - resolveClosed = resolve - }) - this.children.set(child, closed) - // A late error after request timeout must not remove physical-close tracking or crash the host. - const onError = () => {} - child.on('error', onError) - child.once('close', () => { - child.off('error', onError) - this.children.delete(child) - resolveClosed() - }) - } - - dispose(): Promise { - this.fenced = true - if (this.disposal) { - return this.disposal - } - const pending = [...this.children.entries()] - for (const [child] of pending) { - if (!this.signalled.has(child)) { - this.signalled.add(child) - terminateRelaySubprocessTree(child) - } - } - const disposal = this.waitForClose(pending.map(([, closed]) => closed)).finally(() => { - this.disposal = null - }) - this.disposal = disposal - return disposal - } - - private waitForClose(closes: Promise[]): Promise { - if (closes.length === 0) { - return Promise.resolve() - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('relay_agent_execution_shutdown_incomplete')) - }, this.closeDeadlineMs) - timer.unref?.() - void Promise.all(closes).then(() => { - clearTimeout(timer) - resolve() - }) - }) - } -} diff --git a/src/relay/relay-filesystem-watch-registry.ts b/src/relay/relay-filesystem-watch-registry.ts index e0e538b0736f..14f5d5b2ee8e 100644 --- a/src/relay/relay-filesystem-watch-registry.ts +++ b/src/relay/relay-filesystem-watch-registry.ts @@ -1,5 +1,6 @@ import type { RelayDispatcher, RequestContext } from './dispatcher' import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event-batch' +import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions @@ -9,7 +10,7 @@ import { type RelayWatcherProcessPool } from './relay-watcher-process-pool' import { emitRelayWatcherEvents, emitRelayWatcherOverflow } from './relay-watcher-event-emitter' -import { awaitRelayWatcherSetupForClient, startInitialRelayWatch } from './relay-watcher-setup-wait' +import { awaitRelayWatcherSetup, shouldRetryInitialRelayWatch } from './relay-watcher-setup-wait' import { RelayWatcherTeardownTracker, type RelayWatcherTeardownState @@ -26,7 +27,6 @@ import { releaseStaleRelayWatches } from './relay-watcher-stale-client-release' import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' import { joinRelayWatcherPendingSetup } from './relay-watcher-pending-setup-join' import { createRelayWatcherState } from './relay-watcher-state' -import { closeRelayWatchesAndWait, disposeRelayWatchesAndWait } from './relay-watcher-shutdown' const RELAY_WATCH_OPTIONS = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) @@ -40,7 +40,6 @@ export class RelayFilesystemWatchRegistry { ) private readonly teardownTracker: RelayWatcherTeardownTracker private readonly removalFence: RelayWatcherRemovalFence - private disposed = false constructor( private readonly dispatcher: RelayDispatcher, @@ -59,9 +58,6 @@ export class RelayFilesystemWatchRegistry { } watch(rootPath: string, context?: RequestContext, watchId?: number): Promise { - if (this.disposed) { - return Promise.reject(new Error('relay_watcher_shutdown_fenced')) - } const rootKey = normalizeRuntimePathForComparison(rootPath) if (this.removalFence.isActive(rootKey)) { return Promise.reject(new Error('Remote worktree deletion already in progress')) @@ -75,9 +71,7 @@ export class RelayFilesystemWatchRegistry { if (watchId !== undefined) { existing.clientWatchIds.set(clientId, watchId) } - return awaitRelayWatcherSetupForClient(existing, context, () => - this.releaseWatchClient(existing, clientId) - ) + return this.awaitSetupForClient(existing, clientId, context) } void this.closeWatch(existing).catch(() => {}) } @@ -110,9 +104,6 @@ export class RelayFilesystemWatchRegistry { if (capacityRelease) { await capacityRelease } - if (this.disposed) { - throw new Error('relay_watcher_shutdown_fenced') - } const clientId = context?.clientId ?? 0 const isStale = context?.isStale ?? (() => false) const existing = this.watches.get(rootKey) @@ -121,9 +112,7 @@ export class RelayFilesystemWatchRegistry { if (watchId !== undefined) { existing.clientWatchIds.set(clientId, watchId) } - await awaitRelayWatcherSetupForClient(existing, context, () => - this.releaseWatchClient(existing, clientId) - ) + await this.awaitSetupForClient(existing, clientId, context) return } @@ -132,9 +121,7 @@ export class RelayFilesystemWatchRegistry { const state = createRelayWatcherState(rootKey, rootPath, clientId, isStale, watchId) this.watches.set(rootKey, state) state.setupWaiters = new PromiseSettlementWaiters(this.startInitialWatch(state)) - await awaitRelayWatcherSetupForClient(state, context, () => - this.releaseWatchClient(state, clientId) - ) + await this.awaitSetupForClient(state, clientId, context) } unwatch(rootPath: string, context?: RequestContext): void { @@ -196,31 +183,27 @@ export class RelayFilesystemWatchRegistry { } dispose(): void { - this.disposed = true this.watches.forEach((state) => void this.closeWatch(state).catch(() => {})) this.watcherPool.dispose() } - closeWatchesAndWait(): Promise { - this.disposed = true - return closeRelayWatchesAndWait( - this.watches, - this.pendingSetups, - this.teardownTracker, - (state) => this.closeWatch(state) - ) - } - - disposeAndWait = (): Promise => - disposeRelayWatchesAndWait(() => this.closeWatchesAndWait(), this.watcherPool) - - private startInitialWatch(state: RelayWatcherTeardownState): Promise { - return startInitialRelayWatch( - state, - () => this.subscribeState(state), - () => emitRelayWatcherOverflow(this.dispatcher, state.rootPath, state.closed), - () => this.closeWatch(state) - ) + private async startInitialWatch(state: RelayWatcherTeardownState): Promise { + try { + await this.subscribeState(state) + } catch (firstError) { + if (!state.closed && shouldRetryInitialRelayWatch(firstError)) { + try { + await this.subscribeState(state) + emitRelayWatcherOverflow(this.dispatcher, state.rootPath, state.closed) + return + } catch (quarantineError) { + void this.closeWatch(state).catch(() => {}) + throw quarantineError + } + } + void this.closeWatch(state).catch(() => {}) + throw firstError + } } private subscribeState(state: RelayWatcherTeardownState): Promise { @@ -262,13 +245,7 @@ export class RelayFilesystemWatchRegistry { state.generation !== generation || this.watches.get(state.rootKey) !== state ) { - if (state.closed) { - state.subscription = subscription - } await subscription.unsubscribe() - if (state.subscription === subscription) { - state.subscription = null - } return } state.subscription = subscription @@ -299,6 +276,30 @@ export class RelayFilesystemWatchRegistry { }) } + private async awaitSetupForClient( + state: RelayWatcherTeardownState, + clientId: number, + context?: RequestContext + ): Promise { + try { + await awaitRelayWatcherSetup(state.setupWaiters, context?.signal) + } catch (error) { + this.releaseWatchClient(state, clientId) + const expectedAbort = + (error instanceof Error && error.name === 'AbortError') || + (isWatcherProcessFailure(error) && error.code === 'subscribe_aborted') + if (expectedAbort) { + return + } + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`[relay] File watcher not available for ${state.rootPath}: ${message}\n`) + throw error + } + if (context?.isStale()) { + this.releaseWatchClient(state, clientId) + } + } + private releaseClientWatches(clientId: number): void { this.watches.forEach((state) => this.releaseWatchClient(state, clientId)) } diff --git a/src/relay/relay-runtime-owned-process-shutdown.test.ts b/src/relay/relay-runtime-owned-process-shutdown.test.ts index 15bf7c7742ff..48644137c734 100644 --- a/src/relay/relay-runtime-owned-process-shutdown.test.ts +++ b/src/relay/relay-runtime-owned-process-shutdown.test.ts @@ -4,24 +4,21 @@ import { RelayRuntimeServices } from './relay-runtime-services' afterEach(() => vi.restoreAllMocks()) function fixture() { - const agents = vi.fn(async () => {}) const skill = vi.fn(async () => {}) const vault = vi.fn(async () => {}) const responses = vi.fn(async () => {}) const fileStreams = vi.fn(async () => {}) - const watchers = vi.fn(async () => {}) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: disposeOwnedProcesses only reads the owners stubbed here. const runtime = Object.assign(Object.create(RelayRuntimeServices.prototype), { - agentExecHandler: { dispose: agents }, skillInstallHandler: { dispose: skill }, aiVaultService: { dispose: vault }, responseStreams: { disposeAllAndWait: responses }, - fsHandler: { disposeFileStreams: fileStreams, disposeWatchers: watchers } + fsHandler: { disposeFileStreams: fileStreams } }) as RelayRuntimeServices - return { runtime, agents, skill, vault, responses, fileStreams, watchers } + return { runtime, skill, vault, responses, fileStreams } } -it.each(['agents', 'responses', 'fileStreams', 'watchers'] as const)( +it.each(['responses', 'fileStreams'] as const)( 'does not acknowledge cleanup before %s have settled', async (owner) => { const f = fixture() @@ -73,19 +70,3 @@ it('handles hosts without a vault service', async () => { await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() expect(f.vault).not.toHaveBeenCalled() }) - -it('rejects a failed agent or watcher shutdown after cleaning every other owner', async () => { - const f = fixture() - const agentError = new Error('agent cleanup failed') - const watcherError = new Error('watcher cleanup failed') - f.agents.mockRejectedValueOnce(agentError) - f.watchers.mockRejectedValueOnce(watcherError) - await expect(f.runtime.disposeOwnedProcesses()).rejects.toMatchObject({ - message: 'relay_owned_process_shutdown_incomplete', - errors: [agentError, watcherError] - }) - expect(f.skill).toHaveBeenCalledOnce() - expect(f.vault).toHaveBeenCalledOnce() - expect(f.fileStreams).toHaveBeenCalledOnce() - await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() -}) diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts index e52fa6afddf8..d4d179449ba5 100644 --- a/src/relay/relay-runtime-services.ts +++ b/src/relay/relay-runtime-services.ts @@ -32,7 +32,6 @@ export class RelayRuntimeServices { readonly fsHandler: FsHandler readonly gitHandler: GitHandler readonly skillInstallHandler: SkillInstallHandler - readonly agentExecHandler: AgentExecHandler private readonly responseStreams: GitResponseStreamRegistry private readonly aiVaultService: ReturnType | null private readonly sessionSearch: { dispose(): void } | null @@ -80,7 +79,7 @@ export class RelayRuntimeServices { this.skillInstallHandler = new SkillInstallHandler(dispatcher) const externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) const portScanHandler = new PortScanHandler(dispatcher) - this.agentExecHandler = new AgentExecHandler(dispatcher) + const agentExecHandler = new AgentExecHandler(dispatcher) const workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) const hostPlatform = relayPlatform ? getRemoteHostPlatform(relayPlatform) : undefined @@ -106,7 +105,7 @@ export class RelayRuntimeServices { this.skillInstallHandler, externalAutomationsHandler, portScanHandler, - this.agentExecHandler, + agentExecHandler, workspaceSessionHandler, new AiVaultHandler(dispatcher, { hostPlatform, @@ -126,18 +125,12 @@ export class RelayRuntimeServices { // pumps and file descriptors behind it are only proven gone by these registry drains. async disposeOwnedProcesses(): Promise { const failures: unknown[] = [] - const agents = this.agentExecHandler.dispose().catch((error: unknown) => { - failures.push(error) - }) const responses = this.responseStreams.disposeAllAndWait().catch((error: unknown) => { failures.push(error) }) const fileStreams = this.fsHandler.disposeFileStreams().catch((error: unknown) => { failures.push(error) }) - const watchers = this.fsHandler.disposeWatchers().catch((error: unknown) => { - failures.push(error) - }) await this.skillInstallHandler.dispose().catch((error) => { relayLogLine( `[relay] Skill upload cleanup failed: ${error instanceof Error ? error.message : String(error)}` @@ -148,12 +141,10 @@ export class RelayRuntimeServices { `[relay] AI Vault sidecar shutdown failed: ${error instanceof Error ? error.message : String(error)}` ) }) - await agents await responses await fileStreams - await watchers - // Why: an unclosed fd, watcher child or agent child defers shutdown so the next attempt retries - // it; skill/AI Vault cleanup stays log-and-continue until a later T2 slice. + // Why: an unclosed fd defers shutdown so the next attempt retries it; skill/AI Vault + // cleanup stays log-and-continue until the T2 lifecycle port. if (failures.length > 0) { throw new AggregateError(failures, 'relay_owned_process_shutdown_incomplete') } diff --git a/src/relay/relay-watcher-process-pool.ts b/src/relay/relay-watcher-process-pool.ts index 0ff1fe1a59ef..e0093b9a166b 100644 --- a/src/relay/relay-watcher-process-pool.ts +++ b/src/relay/relay-watcher-process-pool.ts @@ -5,8 +5,7 @@ import { WatcherProcessSupervisor } from '../main/ipc/parcel-watcher-process-sup export type RelayWatcherProcessPool = Pick< RuntimeWatcherProcessPool, 'dispose' | 'forgetRoot' | 'subscribe' -> & - Partial> +> export function getRelayWatcherProcessEntryPath(): string { return join(__dirname, 'relay-watcher.js') diff --git a/src/relay/relay-watcher-setup-wait.ts b/src/relay/relay-watcher-setup-wait.ts index f09dfb7470bb..a4c7b348b023 100644 --- a/src/relay/relay-watcher-setup-wait.ts +++ b/src/relay/relay-watcher-setup-wait.ts @@ -1,55 +1,5 @@ import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' import type { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' -import type { RequestContext } from './dispatcher' -import type { RelayWatcherTeardownState } from './relay-watcher-teardown-tracker' - -export async function startInitialRelayWatch( - state: RelayWatcherTeardownState, - subscribe: () => Promise, - emitOverflow: () => void, - close: () => Promise -): Promise { - try { - await subscribe() - } catch (firstError) { - if (!state.closed && shouldRetryInitialRelayWatch(firstError)) { - try { - await subscribe() - emitOverflow() - return - } catch (quarantineError) { - void close().catch(() => {}) - throw quarantineError - } - } - void close().catch(() => {}) - throw firstError - } -} - -export async function awaitRelayWatcherSetupForClient( - state: RelayWatcherTeardownState, - context: RequestContext | undefined, - releaseClient: () => void -): Promise { - try { - await awaitRelayWatcherSetup(state.setupWaiters, context?.signal) - } catch (error) { - releaseClient() - const expectedAbort = - (error instanceof Error && error.name === 'AbortError') || - (isWatcherProcessFailure(error) && error.code === 'subscribe_aborted') - if (expectedAbort) { - return - } - const message = error instanceof Error ? error.message : String(error) - process.stderr.write(`[relay] File watcher not available for ${state.rootPath}: ${message}\n`) - throw error - } - if (context?.isStale()) { - releaseClient() - } -} export function shouldRetryInitialRelayWatch(error: unknown): boolean { return ( diff --git a/src/relay/relay-watcher-shutdown.test.ts b/src/relay/relay-watcher-shutdown.test.ts deleted file mode 100644 index 9cf371969b27..000000000000 --- a/src/relay/relay-watcher-shutdown.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { RelayDispatcher } from './dispatcher' -import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' - -function fixture() { - const unsubscribe = vi.fn(async () => {}) - const pool = { - subscribe: vi.fn(async () => ({ unsubscribe })), - forgetRoot: vi.fn(), - dispose: vi.fn(), - disposeAndWait: vi.fn(async () => {}) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: shutdown paths never reach the dispatcher. - const registry = new RelayFilesystemWatchRegistry({} as RelayDispatcher, pool) - const root = join(tmpdir(), 'orca-watcher-shutdown-fixture') - return { registry, pool, unsubscribe, root } -} - -it('fences new watches synchronously and joins an in-flight native unsubscribe', async () => { - const f = fixture() - await f.registry.watch(f.root) - const pending = Promise.withResolvers() - f.unsubscribe.mockReturnValue(pending.promise) - const finished = vi.fn() - const shutdown = f.registry.closeWatchesAndWait().then(finished) - const duplicate = f.registry.closeWatchesAndWait() - await expect(f.registry.watch(f.root)).rejects.toThrow('relay_watcher_shutdown_fenced') - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - expect(f.unsubscribe).toHaveBeenCalledOnce() - expect(f.pool.dispose).not.toHaveBeenCalled() - pending.resolve() - await Promise.all([shutdown, duplicate]) - expect(f.pool.forgetRoot).toHaveBeenCalledWith(f.root) -}) - -it('retains failed teardown and retries without admitting replacement watches', async () => { - const f = fixture() - await f.registry.watch(f.root) - const failure = new Error('native close failed') - f.unsubscribe.mockRejectedValueOnce(failure) - await expect(f.registry.closeWatchesAndWait()).rejects.toMatchObject({ - message: 'relay_watcher_shutdown_incomplete', - errors: [failure] - }) - expect(f.pool.forgetRoot).not.toHaveBeenCalled() - await expect(f.registry.watch(f.root)).rejects.toThrow('relay_watcher_shutdown_fenced') - await f.registry.closeWatchesAndWait() - expect(f.unsubscribe).toHaveBeenCalledTimes(2) - expect(f.pool.forgetRoot).toHaveBeenCalledOnce() -}) - -it('waits for a late subscription and its unsubscribe after setup has begun', async () => { - const f = fixture() - const setup = Promise.withResolvers<{ unsubscribe: () => Promise }>() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only unsubscribe from a subscription. - f.pool.subscribe.mockReturnValue(setup.promise as ReturnType) - const watch = f.registry.watch(f.root) - const finished = vi.fn() - const shutdown = f.registry.closeWatchesAndWait().then(finished) - const close = Promise.withResolvers() - f.unsubscribe.mockReturnValue(close.promise) - setup.resolve({ unsubscribe: f.unsubscribe }) - await vi.waitFor(() => expect(f.unsubscribe).toHaveBeenCalledOnce()) - expect(finished).not.toHaveBeenCalled() - close.resolve() - await Promise.all([watch, shutdown]) - expect(f.pool.subscribe).toHaveBeenCalledOnce() -}) - -it('joins teardown already started by client unwatch', async () => { - const f = fixture() - await f.registry.watch(f.root) - const close = Promise.withResolvers() - f.unsubscribe.mockReturnValue(close.promise) - f.registry.unwatch(f.root) - const finished = vi.fn() - const shutdown = f.registry.closeWatchesAndWait().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - close.resolve() - await shutdown - expect(f.unsubscribe).toHaveBeenCalledOnce() -}) - -it('retains a late subscription whose cleanup fails instead of treating it as failed setup', async () => { - const f = fixture() - const setup = Promise.withResolvers<{ unsubscribe: () => Promise }>() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only unsubscribe from a subscription. - f.pool.subscribe.mockReturnValue(setup.promise as ReturnType) - const failure = new Error('late native close failed') - f.unsubscribe.mockRejectedValueOnce(failure) - const watch = f.registry.watch(f.root).catch((error: unknown) => error) - const shutdown = f.registry.closeWatchesAndWait().catch((error: unknown) => error) - setup.resolve({ unsubscribe: f.unsubscribe }) - expect(await watch).toBe(failure) - expect(await shutdown).toMatchObject({ - message: 'relay_watcher_shutdown_incomplete', - errors: [failure] - }) - expect(f.pool.forgetRoot).not.toHaveBeenCalled() - await f.registry.closeWatchesAndWait() - expect(f.unsubscribe).toHaveBeenCalledTimes(2) - expect(f.pool.forgetRoot).toHaveBeenCalledOnce() -}) - -it('does not publish a replacement watch when its predecessor closes after shutdown fencing', async () => { - const f = fixture() - await f.registry.watch(f.root) - const close = Promise.withResolvers() - f.unsubscribe.mockReturnValue(close.promise) - f.registry.unwatch(f.root) - const replacement = f.registry.watch(f.root).catch((error: unknown) => error) - const shutdown = f.registry.closeWatchesAndWait() - close.resolve() - expect(await replacement).toMatchObject({ message: 'relay_watcher_shutdown_fenced' }) - await shutdown - expect(f.pool.subscribe).toHaveBeenCalledOnce() -}) - -it('full watcher disposal waits for physical pool exit after native unsubscribe completes', async () => { - const f = fixture() - await f.registry.watch(f.root) - const pending = Promise.withResolvers() - f.pool.disposeAndWait.mockReturnValue(pending.promise) - const finished = vi.fn() - const shutdown = f.registry.disposeAndWait().then(finished) - await vi.waitFor(() => expect(f.pool.forgetRoot).toHaveBeenCalledOnce()) - expect(finished).not.toHaveBeenCalled() - pending.resolve() - await shutdown -}) - -it('does not acknowledge a failed pool exit after native subscriptions close', async () => { - const f = fixture() - await f.registry.watch(f.root) - const error = new Error('pool exit unproven') - f.pool.disposeAndWait.mockRejectedValueOnce(error) - await expect(f.registry.disposeAndWait()).rejects.toMatchObject({ errors: [error] }) - await f.registry.disposeAndWait() -}) diff --git a/src/relay/relay-watcher-shutdown.ts b/src/relay/relay-watcher-shutdown.ts deleted file mode 100644 index bd0963adc52c..000000000000 --- a/src/relay/relay-watcher-shutdown.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { RelayWatcherPendingSetup } from './relay-watcher-setup-tracking' -import type { RelayWatcherProcessPool } from './relay-watcher-process-pool' -import type { - RelayWatcherTeardownState, - RelayWatcherTeardownTracker -} from './relay-watcher-teardown-tracker' - -export async function disposeRelayWatchesAndWait( - closeWatches: () => Promise, - pool: RelayWatcherProcessPool -): Promise { - const close = closeWatches() - const children = Promise.resolve().then(() => { - if (!pool.disposeAndWait) { - throw new Error('relay_watcher_pool_awaited_disposal_unavailable') - } - return pool.disposeAndWait() - }) - const results = await Promise.allSettled([close, children]) - const failures = results.filter((result) => result.status === 'rejected') - if (failures.length > 0) { - throw new AggregateError( - failures.map((failure) => failure.reason), - 'relay_watcher_shutdown_incomplete' - ) - } -} - -export async function closeRelayWatchesAndWait( - watches: ReadonlyMap, - pendingSetups: ReadonlyMap, - tracker: RelayWatcherTeardownTracker, - close: (state: RelayWatcherTeardownState) => Promise -): Promise { - const states = new Set(watches.values()) - for (const root of tracker.rootPaths()) { - const failed = tracker.failedState(root) - if (failed) { - states.add(failed) - } - } - const closures = Promise.allSettled([...states].map(close)) - // Setup refusal is expected after fencing; retained teardown failures remain authoritative. - await Promise.allSettled([...pendingSetups.values()].map((setup) => setup.promise)) - const results = await closures - // Why the fallback: join answers undefined for a root with nothing left in flight. - const remaining = await Promise.allSettled( - tracker.rootPaths().map((root) => tracker.join(root) ?? Promise.resolve()) - ) - const failures = [...results, ...remaining].filter((result) => result.status === 'rejected') - if (failures.length > 0) { - throw new AggregateError( - [...new Set(failures.map((failure) => failure.reason))], - 'relay_watcher_shutdown_incomplete' - ) - } -} diff --git a/src/relay/relay-watcher-teardown-tracker.ts b/src/relay/relay-watcher-teardown-tracker.ts index d9feff34b9f5..785e4dd16ea2 100644 --- a/src/relay/relay-watcher-teardown-tracker.ts +++ b/src/relay/relay-watcher-teardown-tracker.ts @@ -47,7 +47,7 @@ export class RelayWatcherTeardownTracker { }, (error) => { const physicalExit = isWatcherProcessFailure(error) ? error.physicalExit : undefined - if (!subscription && !state.subscription && !physicalExit) { + if (!subscription && !physicalExit) { this.failed.delete(state.rootKey) this.forgetRoot(state.rootPath) return From 982d7aa2ac86c4606db08c1e159dbc3d3d07f654 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:04 -0700 Subject: [PATCH 14/26] Revert "feat(session): retry failed renderer session writes and verify local folder PTYs (#16741 T2 P9) (#24406)" This reverts commit d23ecef30118bf7ccd297d7d1aad37d516a67d47. --- ...cal-pty-registry-folder-workspaces.test.ts | 87 ---------- .../memory/hydrate-local-pty-registry.test.ts | 12 +- src/main/memory/hydrate-local-pty-registry.ts | 20 ++- .../verified-local-folder-workspaces.test.ts | 121 -------------- .../verified-local-folder-workspaces.ts | 40 ----- .../app-shell/use-app-session-persistence.ts | 3 +- ...on-write-subscriber-acknowledgment.test.ts | 129 -------------- ...ession-write-subscriber-allocation.test.ts | 8 +- .../src/lib/session-write-subscriber.ts | 47 +----- ...ssion-split-pane-retirement-fences.test.ts | 157 ------------------ 10 files changed, 24 insertions(+), 600 deletions(-) delete mode 100644 src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts delete mode 100644 src/main/memory/verified-local-folder-workspaces.test.ts delete mode 100644 src/main/memory/verified-local-folder-workspaces.ts delete mode 100644 src/renderer/src/lib/session-write-subscriber-acknowledgment.test.ts delete mode 100644 src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts diff --git a/src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts b/src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts deleted file mode 100644 index c67e4772beeb..000000000000 --- a/src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../shared/folder-workspace-types' -import type { SessionInfo } from '../daemon/types' -import type { Store } from '../persistence' - -const getDaemonProviderMock = vi.fn() -vi.mock('../daemon/daemon-init', () => ({ - getDaemonProvider: () => getDaemonProviderMock() -})) -vi.mock('../project-runtime-git-options', () => ({ - getLocalProjectWorktreeGitOptions: () => ({}) -})) -const listLocalRepoWorktreesStrictMock = vi.fn() -vi.mock('../repo-worktrees', () => ({ - listLocalRepoWorktreesStrict: (...args: unknown[]) => listLocalRepoWorktreesStrictMock(...args) -})) - -function makeStore(folderWorkspaces: FolderWorkspace[]): Store { - const store: Partial = { - getRepos: () => [], - getFolderWorkspaces: () => folderWorkspaces, - getProjectGroups: () => [], - getAllWorktreeMeta: () => ({}), - getAllWorktreeMetaForHost: () => ({}) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: hydration reads only the store members stubbed here. - return store as Store -} - -// Why fresh modules: the hydrator memoizes its pass and the registry is module-scoped. -async function loadFresh() { - vi.resetModules() - const hydrateMod = await import('./hydrate-local-pty-registry') - const registryMod = await import('./pty-registry') - return { - hydrate: hydrateMod.hydrateLocalPtyRegistryAtBoot, - listRegisteredPtys: registryMod.listRegisteredPtys - } -} - -beforeEach(() => { - getDaemonProviderMock.mockReset() - listLocalRepoWorktreesStrictMock.mockReset() -}) - -it('hydrates surviving true folder workspace PTYs without enumerating Git', async () => { - const { hydrate, listRegisteredPtys } = await loadFresh() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership reads only id and executionHostId. - const workspace = { id: 'folder-workspace-1', executionHostId: 'local' } as FolderWorkspace - const session = { sessionId: 'folder:folder-workspace-1@@cafebabe', pid: 4242 } - getDaemonProviderMock.mockReturnValue({ listSessions: vi.fn().mockResolvedValue([session]) }) - - await hydrate(makeStore([workspace])) - - expect(listRegisteredPtys()).toEqual([ - expect.objectContaining({ - ptyId: 'folder:folder-workspace-1@@cafebabe', - worktreeId: 'folder:folder-workspace-1', - pid: 4242 - }) - ]) - expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() -}) - -it.each(['deleted', 'remote', 'ssh'] as const)( - 'rechecks folder ownership after inventory when the catalog becomes %s', - async (change) => { - const { hydrate, listRegisteredPtys } = await loadFresh() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership reads only id and executionHostId. - const folders = [{ id: 'folder-1', executionHostId: 'local' } as FolderWorkspace] - getDaemonProviderMock.mockReturnValue({ - listSessions: vi.fn().mockImplementation(async (): Promise[]> => { - if (change === 'deleted') { - folders.splice(0) - } else { - folders[0].executionHostId = change === 'ssh' ? 'ssh:target-1' : 'runtime:environment-1' - } - return [{ sessionId: 'folder:folder-1@@cafebabe', pid: 4242 }] - }) - }) - - await hydrate(makeStore(folders)) - - expect(listRegisteredPtys()).toEqual([]) - expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() - } -) diff --git a/src/main/memory/hydrate-local-pty-registry.test.ts b/src/main/memory/hydrate-local-pty-registry.test.ts index ffa2f703e03d..b7dd5e1d233a 100644 --- a/src/main/memory/hydrate-local-pty-registry.test.ts +++ b/src/main/memory/hydrate-local-pty-registry.test.ts @@ -60,8 +60,7 @@ function makeStore( kind?: Repo['kind'] path?: string }[] = [], - worktreeMeta: Record = {}, - folderWorkspaces: FolderWorkspace[] = [] + worktreeMeta: Record = {} ): Store { const built: Repo[] = repos.map((r) => ({ id: r.id, @@ -73,18 +72,15 @@ function makeStore( executionHostId: r.executionHostId ?? null, kind: r.kind })) - const store: Partial = { + return { getRepos: () => built, - getFolderWorkspaces: () => folderWorkspaces, - getProjectGroups: () => [], + getFolderWorkspaces: (): FolderWorkspace[] => [], getAllWorktreeMeta: () => worktreeMeta, getAllWorktreeMetaForHost: (hostId) => Object.fromEntries( Object.entries(worktreeMeta).filter(([, meta]) => !meta.hostId || meta.hostId === hostId) ) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: hydration reads only the store members stubbed above. - return store as Store + } as Store } function makeProvider(sessions: SessionInfo[]): Pick { diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts index 6c70dc4c0e62..4e5063b5fc14 100644 --- a/src/main/memory/hydrate-local-pty-registry.ts +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -2,6 +2,7 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/ex import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' import { parsePtySessionId } from '../../shared/pty-session-id-format' +import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree' import { isFolderRepo } from '../../shared/repo-kind' import type { Repo } from '../../shared/repo-types' import { splitWorktreeId, worktreeIdComparisonKey } from '../../shared/worktree/id' @@ -16,7 +17,6 @@ import { readAllWorktreeMetaForHost } from '../persistence/host-qualified-worktr import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { listLocalRepoWorktreesStrict } from '../repo-worktrees' import { listRegisteredPtys, registerPty } from './pty-registry' -import { getVerifiedLocalFolderWorkspaceKeys } from './verified-local-folder-workspaces' type HydrationStore = Store @@ -165,11 +165,6 @@ async function hydrateLocalPtyRegistry( } throwIfSignalAborted(signal) - const verifiedLocalFolderKeys = getVerifiedLocalFolderWorkspaceKeys({ - folderWorkspaces: store.getFolderWorkspaces(), - projectGroups: store.getProjectGroups(), - repos: store.getRepos() - }) for (const info of inventory.sessions) { throwIfSignalAborted(signal) if (alreadyRegistered.has(info.sessionId)) { @@ -191,7 +186,7 @@ async function hydrateLocalPtyRegistry( return complete function isVerifiedLocalWorktree(worktreeId: string): boolean { - if (verifiedLocalFolderKeys.has(worktreeId) || verifiedFolderWorktreeIds.has(worktreeId)) { + if (verifiedFolderWorktreeIds.has(worktreeId)) { return true } const key = worktreeIdComparisonKey(worktreeId) @@ -225,6 +220,17 @@ function getVerifiedFolderWorktreeIds( repoCatalog: LocalRepoCatalog ): Set { const verified = new Set() + const folders = store.getFolderWorkspaces() + const counts = new Map() + for (const folder of folders) { + counts.set(folder.id, (counts.get(folder.id) ?? 0) + 1) + } + for (const folder of folders) { + const worktree = folderWorkspaceToWorktree(folder) + if (counts.get(folder.id) === 1 && worktree.hostId === LOCAL_EXECUTION_HOST_ID) { + verified.add(worktree.id) + } + } const metadata = readAllWorktreeMetaForHost(store, LOCAL_EXECUTION_HOST_ID) for (const [worktreeId, meta] of Object.entries(metadata)) { const parsed = splitWorktreeId(worktreeId) diff --git a/src/main/memory/verified-local-folder-workspaces.test.ts b/src/main/memory/verified-local-folder-workspaces.test.ts deleted file mode 100644 index 594e701561be..000000000000 --- a/src/main/memory/verified-local-folder-workspaces.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { FolderWorkspaceHostState } from '../../shared/folder-workspace-execution-host' -import type { FolderWorkspace } from '../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../shared/project-group-types' -import type { Repo } from '../../shared/repo-types' -import { getVerifiedLocalFolderWorkspaceKeys } from './verified-local-folder-workspaces' - -const folder = (patch: Partial = {}): FolderWorkspace => ({ - id: 'folder-1', - projectGroupId: 'group-1', - name: 'Workspace', - folderPath: '/workspace', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - createdAt: 0, - lastActivityAt: 0, - updatedAt: 0, - ...patch -}) -const repo = (patch: Partial = {}): Repo => ({ - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000000', - addedAt: 0, - ...patch -}) -const projectGroup = (patch: Partial = {}): ProjectGroup => ({ - id: 'group-1', - name: 'Group', - parentPath: null, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 0, - updatedAt: 0, - ...patch -}) -const state = (patch: Partial = {}): FolderWorkspaceHostState => ({ - folderWorkspaces: [folder()], - projectGroups: [], - repos: [], - ...patch -}) - -describe('verified local folder workspace keys', () => { - it.each([undefined, null, 'local'] as const)('accepts local ownership %s', (executionHostId) => { - expect( - getVerifiedLocalFolderWorkspaceKeys( - state({ - folderWorkspaces: [folder({ executionHostId })] - }) - ) - ).toEqual(new Set(['folder:folder-1'])) - }) - - it.each(['ssh:target-1', 'runtime:environment-1', 'invalid', ''])( - 'rejects nonlocal or invalid ownership %s', - (executionHostId) => { - expect( - getVerifiedLocalFolderWorkspaceKeys( - state({ - folderWorkspaces: [ - // Why Object.assign: invalid stored ids must reach the parser without widening the type. - Object.assign(folder(), { executionHostId }) - ] - }) - ) - ).toEqual(new Set()) - } - ) - - it('honors explicit local ownership over legacy scope', () => { - expect( - getVerifiedLocalFolderWorkspaceKeys( - state({ - folderWorkspaces: [folder({ executionHostId: 'local', connectionId: 'target-1' })], - repos: [repo({ executionHostId: 'runtime:environment-1' })] - }) - ) - ).toEqual(new Set(['folder:folder-1'])) - }) - - it.each([ - state({ folderWorkspaces: [] }), - state({ folderWorkspaces: [folder(), folder()] }), - state({ - folderWorkspaces: [ - folder({ executionHostId: 'local' }), - folder({ executionHostId: 'runtime:environment-1' }) - ] - }), - state({ folderWorkspaces: [folder({ connectionId: 'target-1' })] }), - state({ projectGroups: [projectGroup({ connectionId: 'target-1' })] }), - state({ repos: [repo({ executionHostId: 'runtime:environment-1' })] }), - state({ repos: [repo({ executionHostId: 'ssh:target-1' })] }), - state({ repos: [repo(), repo({ id: 'repo-2', connectionId: 'target-1' })] }), - state({ repos: [repo(), repo({ id: 'repo-2', executionHostId: 'runtime:environment-1' })] }) - ])('rejects missing, duplicate, remote, or ambiguous scope %#', (catalog) => { - expect(getVerifiedLocalFolderWorkspaceKeys(catalog)).toEqual(new Set()) - }) - - it('accepts local inferred scope without unrelated runtime repos affecting it', () => { - expect( - getVerifiedLocalFolderWorkspaceKeys( - state({ - repos: [ - repo(), - repo({ id: 'remote', path: '/elsewhere', executionHostId: 'runtime:environment-1' }) - ] - }) - ) - ).toEqual(new Set(['folder:folder-1'])) - }) -}) diff --git a/src/main/memory/verified-local-folder-workspaces.ts b/src/main/memory/verified-local-folder-workspaces.ts deleted file mode 100644 index eae3ee39967a..000000000000 --- a/src/main/memory/verified-local-folder-workspaces.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' -import { - findFolderWorkspaceCandidateRepos, - resolveFolderWorkspaceHost, - type FolderWorkspaceHostState -} from '../../shared/folder-workspace-execution-host' -import { folderWorkspaceKey } from '../../shared/workspace-scope' - -export function getVerifiedLocalFolderWorkspaceKeys(state: FolderWorkspaceHostState): Set { - const counts = new Map() - for (const workspace of state.folderWorkspaces) { - counts.set(workspace.id, (counts.get(workspace.id) ?? 0) + 1) - } - const keys = new Set() - for (const workspace of state.folderWorkspaces) { - if (counts.get(workspace.id) !== 1) { - continue - } - const pin = parseExecutionHostId(workspace.executionHostId) - if (workspace.executionHostId != null) { - if (pin?.kind === 'local') { - keys.add(folderWorkspaceKey(workspace.id)) - } - continue - } - if (resolveFolderWorkspaceHost(state, workspace.id).kind !== 'local') { - continue - } - // The shared legacy resolver projects runtime ownership as local; attribution cannot. - if ( - findFolderWorkspaceCandidateRepos(state, workspace.id).some( - (repo) => getRepoExecutionHostId(repo) !== 'local' - ) - ) { - continue - } - keys.add(folderWorkspaceKey(workspace.id)) - } - return keys -} diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index 6758c058b581..4d1e9987a091 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -125,11 +125,11 @@ export function useAppSessionPersistence(): void { store: useAppStore, shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), subscribeToPersistGateOpen: onDirectSshRemoteWorkspaceApplyWindowClosed, - onPersistError: (error) => console.warn('[session] Local session patch failed:', error), persist: ({ patch }) => { const state = useAppStore.getState() // Why: route each host's worktree-scoped slice to its own partition; return the local write so the remote-workspace upload chain below keeps its ordering. const localWrite = patchWorkspaceSessionByHost(window.api.session, patch, state) + void localWrite const uploadAuthorities = captureRemoteWorkspaceUploadAuthorities(state) const pendingLayoutEdits = state.pendingDirectSshLayoutEditsByTabId if (uploadAuthorities.length > 0) { @@ -193,7 +193,6 @@ export function useAppSessionPersistence(): void { } })() } - return localWrite } }) }, []) diff --git a/src/renderer/src/lib/session-write-subscriber-acknowledgment.test.ts b/src/renderer/src/lib/session-write-subscriber-acknowledgment.test.ts deleted file mode 100644 index ccc239a97be6..000000000000 --- a/src/renderer/src/lib/session-write-subscriber-acknowledgment.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useAppStore, type AppState } from '@/store' -import { - createSessionWriteSubscriber, - type WorkspaceSessionWrite -} from './session-write-subscriber' - -function acknowledgment() { - let resolve!: () => void - let reject!: (error: unknown) => void - const promise = new Promise((yes, no) => { - resolve = yes - reject = no - }) - return { promise, resolve, reject } -} - -describe('session write acknowledgments', () => { - let initial: AppState - let dispose: (() => void) | undefined - beforeEach(() => { - initial = useAppStore.getState() - vi.useFakeTimers() - }) - afterEach(() => { - dispose?.() - dispose = undefined - vi.useRealTimers() - useAppStore.setState(initial, true) - }) - - function setup() { - const first = acknowledgment() - const persist = vi - .fn<(write: WorkspaceSessionWrite) => void | Promise>() - .mockImplementationOnce(() => first.promise) - const onPersistError = vi.fn() - let wake: (() => void) | undefined - dispose = createSessionWriteSubscriber({ - store: useAppStore, - persist, - onPersistError, - shouldSchedulePersist: () => true, - subscribeToPersistGateOpen: (listener) => { - wake = listener - return () => { - wake = undefined - } - } - }) - useAppStore.setState({ - workspaceSessionReady: true, - hydrationSucceeded: true, - activeTabId: 'old' - }) - vi.advanceTimersByTime(200) - return { first, persist, onPersistError, wake: () => wake?.() } - } - - it('serializes writes and preserves a newer edit to the in-flight field', async () => { - const { first, persist } = setup() - useAppStore.setState({ activeTabId: 'new' }) - await vi.advanceTimersByTimeAsync(200) - expect(persist).toHaveBeenCalledTimes(1) - first.resolve() - await vi.advanceTimersByTimeAsync(200) - expect(persist).toHaveBeenCalledTimes(2) - expect(persist.mock.calls[1][0].patch.activeTabId).toBe('new') - }) - - it.each(['gate', 'store'] as const)( - 'retains rejected intent until a %s wake and rebuilds fresh state', - async (source) => { - const { first, persist, onPersistError, wake } = setup() - useAppStore.setState({ activeTabId: 'new' }) - first.reject(new Error('retirement publication deferred')) - await vi.advanceTimersByTimeAsync(10_000) - expect(persist).toHaveBeenCalledTimes(1) - expect(onPersistError).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) - if (source === 'gate') { - wake() - } else { - useAppStore.getState().setCacheTimerStartedAt('tab:pane', Date.now()) - } - await vi.advanceTimersByTimeAsync(200) - expect(persist).toHaveBeenCalledTimes(2) - expect(persist.mock.calls[1][0].patch.activeTabId).toBe('new') - expect(persist.mock.calls[1][0].patch).toHaveProperty('activeRepoId') - } - ) - - it.each(['resolve', 'reject'] as const)( - 'does not resume after disposal and late %s', - async (outcome) => { - const { first, persist, onPersistError, wake } = setup() - useAppStore.setState({ activeTabId: 'new' }) - dispose?.() - if (outcome === 'resolve') { - first.resolve() - } else { - first.reject(new Error('late failure')) - } - wake() - await vi.advanceTimersByTimeAsync(10_000) - expect(persist).toHaveBeenCalledTimes(1) - expect(onPersistError).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - } - ) - - it('retains a synchronous throw for a later wake', async () => { - const persist = vi.fn<(write: WorkspaceSessionWrite) => void>().mockImplementationOnce(() => { - throw new Error('write refused') - }) - dispose = createSessionWriteSubscriber({ store: useAppStore, persist }) - useAppStore.setState({ - workspaceSessionReady: true, - hydrationSucceeded: true, - activeTabId: 'retained' - }) - await vi.advanceTimersByTimeAsync(200) - expect(vi.getTimerCount()).toBe(0) - useAppStore.getState().setCacheTimerStartedAt('tab:pane', Date.now()) - await vi.advanceTimersByTimeAsync(200) - expect(persist).toHaveBeenCalledTimes(2) - expect(persist.mock.calls[1][0].patch.activeTabId).toBe('retained') - }) -}) diff --git a/src/renderer/src/lib/session-write-subscriber-allocation.test.ts b/src/renderer/src/lib/session-write-subscriber-allocation.test.ts index 2532002f9e7b..3f2c5ce284c6 100644 --- a/src/renderer/src/lib/session-write-subscriber-allocation.test.ts +++ b/src/renderer/src/lib/session-write-subscriber-allocation.test.ts @@ -50,9 +50,7 @@ function createHarness() { }, getState: () => state }, - persist: (payload) => { - persisted.push(payload) - } + persist: (payload) => persisted.push(payload) }) return { dispose, @@ -176,9 +174,7 @@ describe('session write subscriber allocation', () => { }, getState: () => state }, - persist: (payload) => { - persisted.push(payload) - }, + persist: (payload) => persisted.push(payload), shouldSchedulePersist: () => gateOpen, subscribeToPersistGateOpen: () => () => {} }) diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index 581d9e875c64..7e7e9cf73605 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -101,8 +101,7 @@ export type SessionWriteSubscriberDeps = { subscribe: (listener: (state: AppState) => void) => () => void getState: () => AppState } - persist: (payload: WorkspaceSessionWrite) => void | Promise - onPersistError?: (error: unknown) => void + persist: (payload: WorkspaceSessionWrite) => void debounceMs?: number } & SessionWritePersistGate @@ -115,14 +114,11 @@ export type SessionWriteSubscriberDeps = { export function createSessionWriteSubscriber({ store, persist, - onPersistError, shouldSchedulePersist, subscribeToPersistGateOpen, debounceMs = 150 }: SessionWriteSubscriberDeps): () => void { let timer: ReturnType | null = null - let disposed = false - let writing = false // Why: the subscriber fires on every store update (agent status, usage // refreshes, runtime title ticks, …). Without this gate each fire reset // the debounce, and when it finally expired buildWorkspaceSessionPayload @@ -137,17 +133,14 @@ export function createSessionWriteSubscriber({ let prevUnifiedTabsSource: UnifiedTabsByWorktree | null = null // Why: this set is the only record that a mutation still owes a write — `prev` has already // advanced past it, and change detection is identity-based, so a field dropped from here can - // never be re-detected. In-flight fields remain owned by that write until acknowledgment; - // failures merge them back without overwriting newer same-field edits. + // never be re-detected. It is retired only by a flush that reached `persist` (or found nothing + // left to write), and by unsubscribe. A closed gate never retires it. const pendingChangedFields = new Set() const terminalTabsProjection = createTerminalSessionTabsProjection() const unifiedTabsProjection = createUnifiedSessionTabsProjection() const flushPendingWrite = (): void => { timer = null - if (disposed || writing || pendingChangedFields.size === 0) { - return - } // Why: rebuild from the freshest store state rather than the snapshot // captured when this timer was scheduled. Today this is equivalent // because buildWorkspaceSessionPayload reads only SESSION_RELEVANT_FIELDS @@ -172,41 +165,10 @@ export function createSessionWriteSubscriber({ if (Object.keys(patch).length === 0) { return } - writing = true - const settle = (failed: boolean, error?: unknown): void => { - writing = false - if (disposed) { - return - } - if (failed) { - for (const field of changed) { - pendingChangedFields.add(field) - } - // A rejection waits for the next store/gate wake, not a retry loop. - onPersistError?.(error) - } else if (pendingChangedFields.size > 0) { - armFlushTimer() - } - } - try { - const result = persist({ patch }) - if (result && typeof result.then === 'function') { - void result.then( - () => settle(false), - (error: unknown) => settle(true, error) - ) - } else { - settle(false) - } - } catch (error) { - settle(true, error) - } + persist({ patch }) } const armFlushTimer = (): void => { - if (disposed || writing) { - return - } if (timer !== null) { clearTimeout(timer) } @@ -310,7 +272,6 @@ export function createSessionWriteSubscriber({ }) return () => { - disposed = true unsub() unsubGateOpen?.() if (timer !== null) { diff --git a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts b/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts deleted file mode 100644 index 345a0db60d46..000000000000 --- a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' -import { collectLeafIds } from '../components/terminal-pane/terminal-pane-layout-tree' -import { - planTerminalLiveLayoutRemovals, - selectRetiredPaneIds, - trackRetiredLeafIds -} from '../components/terminal-pane/terminal-live-layout-reconciliation' -import { applyFreshWebSessionTabsSnapshot } from './web-session-tabs-sync' -import { - clearWebSessionTerminalOrphanRecoveryForTests, - recoverWebSessionTerminalOrphansBeforeApply -} from './web-session-terminal-orphan-recovery' -import { - ENV, - LEAF_ID, - SECOND_LEAF_ID, - makeSnapshot, - makeState, - resetWebSessionTabsSyncTestState -} from './web-session-tabs-sync-test-harness' - -vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() } })) -vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ - observeAgentHookCompletionForNotification: vi.fn() -})) - -const TAB_ID = 'web-terminal-host-tab-1' -const mountedLeaves = [LEAF_ID, SECOND_LEAF_ID] - -function snapshot(version: number, leaves = mountedLeaves): RuntimeMobileSessionTabsResult { - return makeSnapshot( - leaves.map((leafId) => ({ - type: 'terminal' as const, - id: `host-tab-1::${leafId}`, - parentTabId: 'host-tab-1', - leafId, - title: 'shell', - isActive: leafId === LEAF_ID, - status: 'ready' as const, - terminal: `terminal-${leafId}` - })), - { snapshotVersion: version } - ) -} - -function retiredSnapshot(): RuntimeMobileSessionTabsResult { - return { - ...snapshot(3, [LEAF_ID]), - retiredTerminalSurfaces: [ - { - parentTabId: 'host-tab-1', - leafId: SECOND_LEAF_ID, - terminal: `terminal-${SECOND_LEAF_ID}`, - ptyId: 'native-second', - incarnationId: 'inc-second' - } - ] - } -} - -function createReconciliation() { - let state = makeState() - let previousLayoutLeafIds: ReadonlySet = new Set() - let retiredLeafIds: ReadonlySet = new Set() - const mounted = new Set(mountedLeaves) - const call = vi.fn(async () => { - throw new Error('execution host unavailable') - }) - - function plan(secondPtyId: string | null): number[] { - const root = state.terminalLayoutsByTabId[TAB_ID]?.root - expect(root).toBeDefined() - const layoutLeafIds = new Set(root ? collectLeafIds(root) : []) - retiredLeafIds = trackRetiredLeafIds({ - retiredLeafIds, - previousLayoutLeafIds, - layoutLeafIds, - mountedLeafIds: mounted - }) - previousLayoutLeafIds = layoutLeafIds - return selectRetiredPaneIds(planTerminalLiveLayoutRemovals(root, mounted, retiredLeafIds), { - paneCount: mounted.size, - paneIdForLeaf: (leaf) => (leaf === LEAF_ID ? 1 : 2), - ptyIdForPane: (pane) => (pane === 1 ? 'remote:first' : secondPtyId) - }) - } - - return { - call, - plan, - removeSecond: () => mounted.delete(SECOND_LEAF_ID), - leaves: () => collectLeafIds(state.terminalLayoutsByTabId[TAB_ID].root!), - async receive(incoming: RuntimeMobileSessionTabsResult) { - const recovered = await recoverWebSessionTerminalOrphansBeforeApply(state, incoming, ENV, { - call - }) - expect(recovered).not.toBeNull() - if (recovered) { - state = { ...state, ...applyFreshWebSessionTabsSnapshot(state, recovered, ENV) } - } - } - } -} - -describe('host snapshot fences before split-pane retirement', () => { - beforeEach(() => { - resetWebSessionTabsSyncTestState() - clearWebSessionTerminalOrphanRecoveryForTests() - }) - - it('keeps a null-transport pane when an older layout arrives', async () => { - const view = createReconciliation() - await view.receive(snapshot(2)) - expect(view.plan('remote:second')).toEqual([]) - await view.receive(retiredSnapshotWithVersion(1)) - expect(view.leaves()).toEqual(mountedLeaves) - expect(view.plan(null)).toEqual([]) - }) - - it('retains a missing pane when a newer layout cannot verify the host inventory', async () => { - const view = createReconciliation() - await view.receive(snapshot(2)) - view.plan('remote:second') - await view.receive(snapshot(3, [LEAF_ID])) - expect(view.call).toHaveBeenCalled() - expect(view.leaves()).toContain(SECOND_LEAF_ID) - expect(view.plan(null)).toEqual([]) - }) - - it('defers proven retirement until the transport clears and does not close twice', async () => { - const view = createReconciliation() - await view.receive(snapshot(2)) - view.plan('remote:second') - await view.receive(retiredSnapshot()) - expect(view.leaves()).toEqual([LEAF_ID]) - expect(view.plan('remote:second')).toEqual([]) - expect(view.plan(null)).toEqual([2]) - view.removeSecond() - expect(view.plan(null)).toEqual([]) - }) - - it('clears deferred retirement when the host reintroduces the leaf before detach', async () => { - const view = createReconciliation() - await view.receive(snapshot(2)) - view.plan('remote:second') - await view.receive(retiredSnapshot()) - expect(view.plan('remote:second')).toEqual([]) - await view.receive(snapshot(4)) - expect(view.leaves()).toEqual(mountedLeaves) - expect(view.plan(null)).toEqual([]) - }) -}) - -function retiredSnapshotWithVersion(snapshotVersion: number): RuntimeMobileSessionTabsResult { - return { ...retiredSnapshot(), snapshotVersion } -} From 7eae6aafafb650392b06be1f5ca3a0e5ef2a366b Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:04 -0700 Subject: [PATCH 15/26] Revert "feat(ssh): remote orcad primitives on the pinned Node runtime (#16741 T6-1) (#24419)" This reverts commit dd87ae578d12dc06e54244ac35fa7ae6546ae668. --- src/main/ssh/orcad-activation-record-store.ts | 58 ++---- src/main/ssh/orcad-active-readiness.ts | 90 --------- src/main/ssh/orcad-remote-build-hash.ts | 36 ---- src/main/ssh/orcad-remote-context.ts | 59 ------ src/main/ssh/orcad-remote-deploy.test.ts | 30 ++- src/main/ssh/orcad-remote-deploy.ts | 49 +++-- src/main/ssh/orcad-remote-launch.ts | 19 +- src/main/ssh/orcad-remote-primitives.test.ts | 186 ------------------ src/main/ssh/orcad-remote-record-file.ts | 68 ------- src/main/ssh/orcad-remote-rollback.test.ts | 22 +-- src/main/ssh/orcad-remote-rollback.ts | 53 +++-- src/main/ssh/orcad-remote-runtime-control.ts | 63 ------ 12 files changed, 116 insertions(+), 617 deletions(-) delete mode 100644 src/main/ssh/orcad-active-readiness.ts delete mode 100644 src/main/ssh/orcad-remote-build-hash.ts delete mode 100644 src/main/ssh/orcad-remote-context.ts delete mode 100644 src/main/ssh/orcad-remote-primitives.test.ts delete mode 100644 src/main/ssh/orcad-remote-record-file.ts delete mode 100644 src/main/ssh/orcad-remote-runtime-control.ts diff --git a/src/main/ssh/orcad-activation-record-store.ts b/src/main/ssh/orcad-activation-record-store.ts index a36dcc728e05..d60634ec525d 100644 --- a/src/main/ssh/orcad-activation-record-store.ts +++ b/src/main/ssh/orcad-activation-record-store.ts @@ -6,50 +6,32 @@ * activated" would deploy over a live install and lose its rollback target. */ import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' import { RELAY_REMOTE_DIR } from './relay-protocol' import { ORCAD_ACTIVATION_FILENAME, emptyOrcadActivationRecord, parseOrcadActivationRecord, - serializeOrcadActivationRecord, - type OrcadActivationReadResult, type OrcadActivationRecord } from './orcad-activation-record' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { - readBoundedOrcadRemoteRecord, - writeAtomicOrcadRemoteRecord -} from './orcad-remote-record-file' -const ORCAD_ACTIVATION_RECORD_MAX_BYTES = 64 * 1024 +export function orcadActivationPath(host: RemoteHostPlatform, remoteHome: string): string { + return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, ORCAD_ACTIVATION_FILENAME) +} -type ActivationRecordTarget = { +export async function readOrcadActivationRecord(options: { conn: SshConnection host: RemoteHostPlatform remoteHome: string signal?: AbortSignal -} - -export function orcadActivationPath(host: RemoteHostPlatform, remoteHome: string): string { - return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, ORCAD_ACTIVATION_FILENAME) -} - -/** A failed read rejects; it never reads as absent, because absence would admit a fresh deploy. */ -async function readActivationRecordState( - options: ActivationRecordTarget -): Promise { - const read = await readBoundedOrcadRemoteRecord( - options, - orcadActivationPath(options.host, options.remoteHome), - ORCAD_ACTIVATION_RECORD_MAX_BYTES - ) - return read.state === 'absent' ? read : parseOrcadActivationRecord(read.raw) -} - -export async function readOrcadActivationRecord( - options: ActivationRecordTarget -): Promise { - const parsed = await readActivationRecordState(options) +}): Promise { + const path = orcadActivationPath(options.host, options.remoteHome) + const raw = await execCommand(options.conn, `cat ${shellQuote(path)} 2>/dev/null || true`, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + }).catch(() => '') + const parsed = parseOrcadActivationRecord(raw) if (parsed.state === 'ok') { return parsed.record } @@ -61,18 +43,6 @@ export async function readOrcadActivationRecord( return emptyOrcadActivationRecord() } -/** Refuses to replace a record this client cannot read, e.g. one a newer client wrote. */ -export async function writeOrcadActivationRecord( - options: ActivationRecordTarget, - record: OrcadActivationRecord -): Promise { - const existing = await readActivationRecordState(options) - if (existing.state === 'unreadable') { - throw new Error(`Refusing to overwrite this host's orcad activation record: ${existing.reason}`) - } - await writeAtomicOrcadRemoteRecord( - options, - orcadActivationPath(options.host, options.remoteHome), - serializeOrcadActivationRecord(record) - ) +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` } diff --git a/src/main/ssh/orcad-active-readiness.ts b/src/main/ssh/orcad-active-readiness.ts deleted file mode 100644 index 40beecb236ce..000000000000 --- a/src/main/ssh/orcad-active-readiness.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** Proving that the orcad an activation record names is the one actually serving. */ -import { evaluateOrcadActivation, type OrcadActivationExpectation } from './orcad-activation-gate' -import { - orcadLivenessProbeCommand, - parseOrcadLiveness, - parseOrcadReadinessOutput, - readOrcadReadinessCommand, - type OrcadLaunchSpec, - type OrcadReadinessParse -} from './orcad-remote-launch' -import { - execOrcadRemote, - launchOrcadAndAwaitReadiness, - type OrcadRemoteExecTarget -} from './orcad-remote-runtime-control' -import type { ServeReadiness } from '../server/serve-readiness' - -/** `exited` is proven absence; `unverifiable` means the host could not say, which is not death. */ -export type OrcadReadinessFailureVerdict = 'exited' | 'unverifiable' | 'rejected' - -export class OrcadActiveReadinessError extends Error { - constructor( - readonly verdict: OrcadReadinessFailureVerdict, - message: string - ) { - super(message) - this.name = 'OrcadActiveReadinessError' - } -} - -function gatedReadiness( - parsed: OrcadReadinessParse, - expectation: OrcadActivationExpectation, - label: string -): ServeReadiness { - const readiness = parsed.state === 'ready' ? parsed.readiness : null - const verdict = evaluateOrcadActivation(readiness, expectation) - if (verdict.decision === 'reject') { - throw new OrcadActiveReadinessError( - 'rejected', - `${label} failed its readiness check: ${verdict.reason}` - ) - } - if (!readiness) { - throw new OrcadActiveReadinessError( - 'rejected', - `${label} passed activation without a readiness payload.` - ) - } - return readiness -} - -/** Checks a recorded-active slot without starting anything. */ -export async function probeActiveOrcadReadiness( - target: OrcadRemoteExecTarget & { remoteInstallDir: string }, - expectation: OrcadActivationExpectation -): Promise { - const liveness = parseOrcadLiveness( - await execOrcadRemote(target, orcadLivenessProbeCommand(target.host, target.remoteInstallDir)) - ) - if (liveness === 'DEAD') { - throw new OrcadActiveReadinessError( - 'exited', - `orcad ${expectation.fullVersion} is recorded active but its process has exited.` - ) - } - if (liveness !== 'LIVE') { - throw new OrcadActiveReadinessError( - 'unverifiable', - `orcad ${expectation.fullVersion} process state is unverifiable.` - ) - } - const parsed = parseOrcadReadinessOutput( - await execOrcadRemote(target, readOrcadReadinessCommand(target.host, target.remoteInstallDir)) - ) - return gatedReadiness(parsed, expectation, 'The active orcad') -} - -/** Starts a slot (recovery or rollback) and accepts it only if it proves the expected build. */ -export async function launchOrcadSlotAndAwaitReadiness( - target: OrcadRemoteExecTarget & { - readinessTimeoutMs?: number - sleep?: (ms: number) => Promise - }, - spec: OrcadLaunchSpec, - expectation: OrcadActivationExpectation -): Promise { - const parsed = await launchOrcadAndAwaitReadiness(target, spec) - return gatedReadiness(parsed, expectation, `orcad ${spec.fullVersion}`) -} diff --git a/src/main/ssh/orcad-remote-build-hash.ts b/src/main/ssh/orcad-remote-build-hash.ts deleted file mode 100644 index 7ed6384dfe78..000000000000 --- a/src/main/ssh/orcad-remote-build-hash.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** The installed slot's `orcad.js` identity, the same 16-hex prefix orcad reports in its health. */ -import { shellEscape } from './ssh-connection-utils' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -const BUILD_HASH_MARKER = '__ORCAD_BUILD_HASH__' - -export async function readRemoteOrcadBuildHash( - target: OrcadRemoteExecTarget, - remoteInstallDir: string -): Promise { - const output = await execOrcadRemote( - target, - remoteOrcadBuildHashCommand(target.host, remoteInstallDir) - ) - const match = output.match(/__ORCAD_BUILD_HASH__\s+([a-fA-F0-9]{16})/u) - if (!match?.[1]) { - throw new Error('Could not verify the installed orcad build hash.') - } - return match[1].toLowerCase() -} - -export function remoteOrcadBuildHashCommand( - host: RemoteHostPlatform, - remoteInstallDir: string -): string { - assertPosixOrcadHost(host) - const path = shellEscape(joinRemotePath(host, remoteInstallDir, 'orcad.js')) - // Why both tools: GNU/busybox ship sha256sum, macOS ships shasum; either prints the digest first. - return [ - `orca_hash=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum ${path} | awk '{print $1}';`, - `elif command -v shasum >/dev/null 2>&1; then shasum -a 256 ${path} | awk '{print $1}'; fi);`, - `case "$orca_hash" in [0-9a-fA-F][0-9a-fA-F]*) printf '%s %.16s\\n' ${shellEscape(BUILD_HASH_MARKER)} "$orca_hash";; esac` - ].join(' ') -} diff --git a/src/main/ssh/orcad-remote-context.ts b/src/main/ssh/orcad-remote-context.ts deleted file mode 100644 index 6b893ba29500..000000000000 --- a/src/main/ssh/orcad-remote-context.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** Everything a managed-orcad operation needs to know about one SSH host before it acts. */ -import type { ServerTarget } from '../../shared/node-runtime-pin' -import type { SshTarget } from '../../shared/ssh-types' -import type { OrcadActivationRecord } from './orcad-activation-record' -import { readOrcadActivationRecord } from './orcad-activation-record-store' -import { resolveOrcadDeploymentTarget } from './orcad-deployment-target' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { execOrcadRemote } from './orcad-remote-runtime-control' -import type { SshConnection } from './ssh-connection' -import { readRemoteHomeCommand } from './ssh-remote-commands' -import { - joinRemotePath, - normalizeRemoteHome, - validateRemoteHome, - type RemoteHostPlatform -} from './ssh-remote-platform' -import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' - -export type OrcadRemoteContext = { - activationRecord: OrcadActivationRecord - serverTarget: ServerTarget - connection: SshConnection - host: RemoteHostPlatform - remoteHome: string - target: SshTarget - userDataDir: string -} - -export async function resolveOrcadRemoteContext( - target: SshTarget, - connection: SshConnection, - signal?: AbortSignal -): Promise { - const host = await detectRemoteHostPlatform(connection, { signal }) - if (!host) { - throw new Error('This SSH host platform is not supported by managed orcad.') - } - // Why first: every lifecycle step after this is POSIX-only, so refuse before probing further. - assertPosixOrcadHost(host) - const remote = { conn: connection, host, signal } - const remoteHome = normalizeRemoteHome( - await execOrcadRemote(remote, readRemoteHomeCommand(host)), - host - ) - if (!validateRemoteHome(remoteHome, host)) { - throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) - } - const serverTarget = await resolveOrcadDeploymentTarget({ conn: connection, host, signal }) - const activationRecord = await readOrcadActivationRecord({ ...remote, remoteHome }) - return { - activationRecord, - serverTarget, - connection, - host, - remoteHome, - target, - userDataDir: joinRemotePath(host, remoteHome, '.orca') - } -} diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index be2dd5223d29..8308ff52db57 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -1,5 +1,4 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' -import type * as RecordFile from './orcad-remote-record-file' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -18,10 +17,6 @@ vi.mock('./ssh-relay-install-transfers', () => ({ uploadRelayDirectory: vi.fn().mockResolvedValue(undefined), writeRelayFile: vi.fn().mockResolvedValue(undefined) })) -vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ - ...(await importOriginal()), - writeAtomicOrcadRemoteRecord: vi.fn().mockResolvedValue(undefined) -})) vi.mock('./orcad-remote-node-runtime', () => ({ ensureRemoteOrcadNodeRuntime: vi.fn().mockResolvedValue(undefined) })) @@ -31,8 +26,7 @@ vi.mock('./orcad-local-build-hash', () => ({ import { execCommand } from './ssh-relay-deploy-helpers' import { acquireInstallLock } from './ssh-relay-install-lock' -import { uploadRelayDirectory } from './ssh-relay-install-transfers' -import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' +import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' import { deployOrcad, type OrcadDeployOptions } from './orcad-remote-deploy' import { installOrcadBundle } from './orcad-remote-install' import { ensureRemoteOrcadNodeRuntime } from './orcad-remote-node-runtime' @@ -112,12 +106,10 @@ type HostScript = { function scriptHost(script: HostScript): void { mockExec.mockImplementation(async (_conn, command: string) => { const text = String(command) - if (text.includes('__ORCAD_RECORD_PRESENT__') && text.includes('orcad-active.json')) { + if (text.startsWith('cat ') && text.includes('orcad-active.json')) { return script.activationRecord - ? `__ORCAD_RECORD_PRESENT__\n${script.activationRecord}` - : '__ORCAD_RECORD_ABSENT__\n' } - if (text.includes('.orcad-readiness') && text.startsWith('head -c ')) { + if (text.includes('.orcad-readiness') && text.startsWith('cat ')) { if (script.readinessAtMs !== undefined && Date.now() < script.readinessAtMs) { return '' } @@ -280,8 +272,8 @@ describe('deployOrcad', () => { expect(script.log).toEqual(['preflight']) expect( vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.some(([, path]) => path.includes('orcad-active.json')) + .mocked(writeRelayFile) + .mock.calls.some(([, , path]) => path.includes('orcad-active.json')) ).toBe(false) } ) @@ -364,7 +356,7 @@ describe('deployOrcad', () => { if (String(command).startsWith('chmod 755 ')) { throw new Error('chmod failed') } - return String(command).includes('__ORCAD_RECORD_ABSENT__') ? '__ORCAD_RECORD_ABSENT__\n' : '' + return '' }) await expect(deployOrcad(options())).rejects.toThrow('chmod failed') expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() @@ -449,9 +441,9 @@ describe('deployOrcad', () => { const result = await deployOrcad(options()) expect(result).toMatchObject({ outcome: 'installed-and-activated', fullVersion: NEW_VERSION }) const written = vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.find((call) => String(call[1]).endsWith('orcad-active.json')) - expect(JSON.parse(String(written?.[2]))).toMatchObject({ + .mocked(writeRelayFile) + .mock.calls.find((call) => String(call[2]).endsWith('orcad-active.json')) + expect(JSON.parse(String(written?.[3]))).toMatchObject({ active: NEW_VERSION, previous: OLD_VERSION }) @@ -500,8 +492,8 @@ describe('deployOrcad', () => { expect(result).toMatchObject({ code: 'orcad_activation_daemon_degraded' }) expect( vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.some((call) => String(call[1]).endsWith('orcad-active.json')) + .mocked(writeRelayFile) + .mock.calls.some((call) => String(call[2]).endsWith('orcad-active.json')) ).toBe(false) }) diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index 95c184e47b43..f296e08bd363 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -4,24 +4,28 @@ * preserve current state and the prelaunch snapshot for explicit recovery. */ import type { SshConnection } from './ssh-connection' +import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' import { execCommand } from './ssh-relay-deploy-helpers' import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { writeRelayFile } from './ssh-relay-install-transfers' import { computeRemoteInstallDir, readLocalFullVersion } from './ssh-relay-versioned-install' import { RELAY_REMOTE_DIR } from './relay-protocol' import { ORCAD_STATE_SNAPSHOT_DIR, + serializeOrcadActivationRecord, withActivatedVersion, type OrcadActivationRecord, type OrcadStateSnapshot } from './orcad-activation-record' -import { - readOrcadActivationRecord, - writeOrcadActivationRecord -} from './orcad-activation-record-store' +import { orcadActivationPath, readOrcadActivationRecord } from './orcad-activation-record-store' import { evaluateOrcadActivation, type OrcadActivationVerdict } from './orcad-activation-gate' import { planOrcadUpdate, type OrcadTerminalCensus } from './orcad-update-plan' -import { ORCAD_LOG_FILENAME } from './orcad-remote-launch' -import { launchOrcadAndAwaitReadiness } from './orcad-remote-runtime-control' +import { + ORCAD_LOG_FILENAME, + orcadLaunchCommand, + parseOrcadReadinessOutput, + readOrcadReadinessCommand +} from './orcad-remote-launch' import { rejectedOrcadStateRecoveryRefusal, stopOutgoingOrcad } from './orcad-remote-deploy-stop' import { captureOrcadStateSnapshotCommand, @@ -77,6 +81,7 @@ export type OrcadDeployResult = | { outcome: 'already-active'; fullVersion: string } | { outcome: 'installed-not-activated'; fullVersion: string; code: string; reason: string } +const READINESS_POLL_MS = 500 const STOP_WAIT_SECONDS = 20 function exec(options: OrcadDeployOptions, command: string): Promise { @@ -130,12 +135,29 @@ async function captureSnapshot( } } -function launchAndAwaitReadiness( +async function launchAndAwaitReadiness( options: OrcadDeployOptions, remoteInstallDir: string, fullVersion: string -): ReturnType { - return launchOrcadAndAwaitReadiness(options, { ...options, remoteInstallDir, fullVersion }) +): Promise> { + await exec( + options, + orcadLaunchCommand(options.host, { ...options, remoteInstallDir, fullVersion }) + ) + const deadline = Date.now() + (options.readinessTimeoutMs ?? ORCAD_STARTUP_READINESS_TIMEOUT_MS) + const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) + let last = parseOrcadReadinessOutput('') + while (Date.now() < deadline) { + options.signal?.throwIfAborted() + last = parseOrcadReadinessOutput( + await exec(options, readOrcadReadinessCommand(options.host, remoteInstallDir)) + ) + if (last.state !== 'pending') { + return last + } + await sleep(READINESS_POLL_MS) + } + return last } /** Restart the incumbent only when the candidate left shared state unchanged. */ @@ -298,9 +320,12 @@ export async function deployOrcad(input: OrcadDeployOptions): Promise/dev/null || true` + return `cat ${readiness} 2>/dev/null || true` } /** @@ -137,23 +134,19 @@ export type OrcadReadinessParse = * not `malformed` — reporting a parse failure for a race would fail deploys that were fine. */ export function parseOrcadReadinessOutput(raw: string): OrcadReadinessParse { - if (Buffer.byteLength(raw, 'utf8') > ORCAD_READINESS_MAX_BYTES) { - return { state: 'malformed', reason: 'readiness payload exceeds the 256 KiB limit' } - } const lines = raw.split('\n') - for (const [index, line] of lines.entries()) { + let sawCandidate = false + for (const line of lines) { const trimmed = line.trim() if (!trimmed.startsWith('{')) { continue } + sawCandidate = true let parsed: unknown try { parsed = JSON.parse(trimmed) } catch { - // Only the unterminated last line can still be mid-write; a finished bad line will stay bad. - return index === lines.length - 1 - ? { state: 'pending' } - : { state: 'malformed', reason: 'readiness line is not valid JSON' } + continue } if (typeof parsed !== 'object' || parsed === null) { continue @@ -167,7 +160,7 @@ export function parseOrcadReadinessOutput(raw: string): OrcadReadinessParse { } return { state: 'ready', readiness: toServeReadiness(payload as Record) } } - return { state: 'pending' } + return sawCandidate ? { state: 'pending' } : { state: 'pending' } } function toServeReadiness(payload: Record): ServeReadiness { diff --git a/src/main/ssh/orcad-remote-primitives.test.ts b/src/main/ssh/orcad-remote-primitives.test.ts deleted file mode 100644 index ab7e6813f987..000000000000 --- a/src/main/ssh/orcad-remote-primitives.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: vi.fn(), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && error.message === 'channel lost' -})) - -vi.mock('./ssh-remote-platform-detection', () => ({ detectRemoteHostPlatform: vi.fn() })) - -import { execCommand } from './ssh-relay-deploy-helpers' -import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' -import { resolveOrcadRemoteContext } from './orcad-remote-context' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' -import { - readBoundedOrcadRemoteRecord, - writeAtomicOrcadRemoteRecord -} from './orcad-remote-record-file' -import { - readOrcadActivationRecord, - writeOrcadActivationRecord -} from './orcad-activation-record-store' -import { emptyOrcadActivationRecord } from './orcad-activation-record' -import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash' -import { OrcadActiveReadinessError, probeActiveOrcadReadiness } from './orcad-active-readiness' -import { parseOrcadReadinessOutput } from './orcad-remote-launch' - -const mockExec = vi.mocked(execCommand) -const linux = getRemoteHostPlatform('linux-x64') -const windows = getRemoteHostPlatform('win32-x64') -const conn: SshConnection = Object.create(null) -const target = { conn, host: linux } -const BUILD_HASH = 'abc123def4567890' - -function readyLine(buildHash = BUILD_HASH): string { - return JSON.stringify({ - type: 'orca_server_ready', - runtimeId: 'r1', - boundEndpoint: 'ws://127.0.0.1:7777', - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, - health: { - buildHash, - buildVersion: '0.2.0+bb01', - nodeVersion: '24.21.0', - nodeAbi: '137', - platform: 'linux', - arch: 'x64', - pid: 1, - terminalDaemon: { - state: 'live', - ownsFreshSessions: true, - pid: 2, - buildVersion: '0.2.0+bb01', - entryPath: '/x/daemon-entry.js', - protocolVersion: 38, - selfTest: { ok: true, coverage: 'pty-spawn', verdict: 'healthy', durationMs: 5 } - } - } - }) -} - -beforeEach(() => { - mockExec.mockReset() -}) - -describe('orcad host record files', () => { - it('tells an absent record from one whose read gave no answer', async () => { - mockExec.mockResolvedValueOnce('__ORCAD_RECORD_ABSENT__\n') - await expect(readBoundedOrcadRemoteRecord(target, '/r.json', 64)).resolves.toEqual({ - state: 'absent' - }) - mockExec.mockResolvedValueOnce('__ORCAD_RECORD_PRESENT__\n{"a":1}') - await expect(readBoundedOrcadRemoteRecord(target, '/r.json', 64)).resolves.toEqual({ - state: 'present', - raw: '{"a":1}' - }) - mockExec.mockResolvedValueOnce('') - await expect(readBoundedOrcadRemoteRecord(target, '/r.json', 64)).rejects.toThrow( - 'no verifiable answer' - ) - }) - - it('keeps the partial file when the write may still be running on the host', async () => { - mockExec.mockRejectedValueOnce(new Error('channel lost')) - await expect(writeAtomicOrcadRemoteRecord(target, '/r.json', '{}')).rejects.toThrow() - expect(mockExec).toHaveBeenCalledOnce() - mockExec.mockRejectedValueOnce(new Error('exit 1')).mockResolvedValueOnce('') - await expect(writeAtomicOrcadRemoteRecord(target, '/r.json', '{}')).rejects.toThrow() - expect(String(mockExec.mock.calls.at(-1)?.[1])).toContain('rm -f') - }) - - it('refuses Windows hosts, which the orcad lifecycle does not support', async () => { - await expect( - readBoundedOrcadRemoteRecord({ conn, host: windows }, 'C:/r.json', 64) - ).rejects.toThrow() - expect(mockExec).not.toHaveBeenCalled() - }) -}) - -describe('activation record store', () => { - const options = { conn, host: linux, remoteHome: '/home/u' } - const newer = JSON.stringify({ ...emptyOrcadActivationRecord(), schemaVersion: 2 }) - - it('reads a lost read as an error, never as an empty record', async () => { - mockExec.mockRejectedValueOnce(new Error('channel lost')) - await expect(readOrcadActivationRecord(options)).rejects.toThrow('channel lost') - }) - - it('reads a newer schema as unreadable and never overwrites it', async () => { - mockExec.mockResolvedValue(`__ORCAD_RECORD_PRESENT__\n${newer}`) - await expect(readOrcadActivationRecord(options)).rejects.toThrow('schemaVersion 2') - await expect(writeOrcadActivationRecord(options, emptyOrcadActivationRecord())).rejects.toThrow( - 'Refusing to overwrite' - ) - expect(mockExec.mock.calls.some(([, command]) => String(command).includes('mv -f'))).toBe(false) - }) - - it('writes atomically when the host has no record yet', async () => { - mockExec.mockResolvedValueOnce('__ORCAD_RECORD_ABSENT__\n').mockResolvedValueOnce('') - await writeOrcadActivationRecord(options, emptyOrcadActivationRecord()) - expect(String(mockExec.mock.calls[1]?.[1])).toMatch(/printf %s .* && mv -f /s) - }) -}) - -describe('installed build identity and readiness', () => { - const slot = { ...target, remoteInstallDir: '/home/u/.orca-remote/orcad-0.2.0+bb01' } - const expectation = { buildHash: BUILD_HASH, fullVersion: '0.2.0+bb01' } - - it('reads the 16-hex build hash the slot reports', async () => { - mockExec.mockResolvedValueOnce(`noise\n__ORCAD_BUILD_HASH__ ${BUILD_HASH.toUpperCase()}\n`) - await expect(readRemoteOrcadBuildHash(target, '/slot')).resolves.toBe(BUILD_HASH) - mockExec.mockResolvedValueOnce('') - await expect(readRemoteOrcadBuildHash(target, '/slot')).rejects.toThrow() - }) - - it.each([ - ['DEAD', 'exited'], - ['UNKNOWN', 'unverifiable'], - ['', 'unverifiable'] - ])('reports a %j liveness probe as %s', async (liveness, verdict) => { - mockExec.mockResolvedValueOnce(liveness) - await expect(probeActiveOrcadReadiness(slot, expectation)).rejects.toMatchObject({ - verdict - }) - }) - - it('accepts only the expected build once the process is live', async () => { - mockExec.mockResolvedValueOnce('LIVE').mockResolvedValueOnce(`${readyLine()}\n`) - await expect(probeActiveOrcadReadiness(slot, expectation)).resolves.toMatchObject({ - runtimeId: 'r1' - }) - mockExec - .mockResolvedValueOnce('LIVE') - .mockResolvedValueOnce(`${readyLine('ffffffffffffffff')}\n`) - const rejected = probeActiveOrcadReadiness(slot, expectation) - await expect(rejected).rejects.toBeInstanceOf(OrcadActiveReadinessError) - await expect(rejected).rejects.toMatchObject({ verdict: 'rejected' }) - }) -}) - -describe('readiness parsing bounds', () => { - it('waits on a half-written last line but rejects a finished invalid one', () => { - expect(parseOrcadReadinessOutput('{"type":"orca_ser')).toEqual({ state: 'pending' }) - expect(parseOrcadReadinessOutput('{"type":"orca_ser\n')).toMatchObject({ - state: 'malformed' - }) - }) - - it('rejects a payload over the size cap', () => { - expect(parseOrcadReadinessOutput('x'.repeat(256 * 1024 + 1))).toMatchObject({ - state: 'malformed' - }) - }) -}) - -describe('orcad remote context', () => { - it('refuses a Windows host before probing anything else', async () => { - vi.mocked(detectRemoteHostPlatform).mockResolvedValueOnce(windows) - const sshTarget = { id: 't', label: 't', host: 'h', port: 22, username: 'u' } - await expect(resolveOrcadRemoteContext(sshTarget, conn)).rejects.toThrow() - expect(mockExec).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/orcad-remote-record-file.ts b/src/main/ssh/orcad-remote-record-file.ts deleted file mode 100644 index 5c29c3b3ef5c..000000000000 --- a/src/main/ssh/orcad-remote-record-file.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Small JSON records Orca keeps on an orcad host (activation, transactions, stop receipts). - * - * Reads are bounded and never swallow a failure: a record that could not be read is not an - * absent one, and treating it as absent is how a client deploys over a live install. - */ -import { randomUUID } from 'node:crypto' -import { shellEscape } from './ssh-connection-utils' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { removeRemoteFileCommand } from './ssh-remote-commands' -import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' - -const ABSENT_MARKER = '__ORCAD_RECORD_ABSENT__' -const PRESENT_MARKER = '__ORCAD_RECORD_PRESENT__' - -/** `present` carries raw bytes; schema checks belong to the caller that owns the format. */ -export type OrcadRemoteRecordRead = { state: 'absent' } | { state: 'present'; raw: string } - -export async function readBoundedOrcadRemoteRecord( - target: OrcadRemoteExecTarget, - path: string, - maxBytes: number -): Promise { - assertPosixOrcadHost(target.host) - const file = shellEscape(path) - // Why markers: an empty stdout must never be mistaken for "no record" when the read failed. - const output = await execOrcadRemote( - target, - `if [ ! -e ${file} ] && [ ! -L ${file} ]; then printf '%s\\n' ${ABSENT_MARKER}; exit 0; fi; ` + - `[ -f ${file} ] || exit 65; size=$(wc -c < ${file}) || exit 65; ` + - `[ "$size" -le ${maxBytes} ] || exit 65; ` + - `printf '%s\\n' ${PRESENT_MARKER}; cat ${file}` - ) - const newline = output.indexOf('\n') - const marker = (newline === -1 ? output : output.slice(0, newline)).trim() - if (marker === ABSENT_MARKER) { - return { state: 'absent' } - } - if (marker !== PRESENT_MARKER) { - throw new Error('orcad host record read returned no verifiable answer') - } - return { state: 'present', raw: newline === -1 ? '' : output.slice(newline + 1) } -} - -export async function writeAtomicOrcadRemoteRecord( - target: OrcadRemoteExecTarget, - path: string, - contents: string -): Promise { - assertPosixOrcadHost(target.host) - const partialPath = `${path}.partial.${process.pid}.${randomUUID()}` - try { - await execOrcadRemote( - target, - `umask 077; printf %s ${shellEscape(contents)} > ${shellEscape(partialPath)} && ` + - `mv -f ${shellEscape(partialPath)} ${shellEscape(path)}` - ) - } catch (error) { - // Why keep the partial on an unconfirmed termination: the write may still be running there. - if (!isUnconfirmedSshCommandTermination(error)) { - await execOrcadRemote(target, removeRemoteFileCommand(target.host, partialPath)).catch( - () => {} - ) - } - throw error - } -} diff --git a/src/main/ssh/orcad-remote-rollback.test.ts b/src/main/ssh/orcad-remote-rollback.test.ts index 5d2dd2e558eb..4d2f9150a75c 100644 --- a/src/main/ssh/orcad-remote-rollback.test.ts +++ b/src/main/ssh/orcad-remote-rollback.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RecordFile from './orcad-remote-record-file' vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn(), @@ -10,13 +9,9 @@ vi.mock('./ssh-relay-install-transfers', () => ({ writeRelayFile: vi.fn().mockResolvedValue(undefined), uploadRelayDirectory: vi.fn().mockResolvedValue(undefined) })) -vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ - ...(await importOriginal()), - writeAtomicOrcadRemoteRecord: vi.fn().mockResolvedValue(undefined) -})) import { execCommand } from './ssh-relay-deploy-helpers' -import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' +import { writeRelayFile } from './ssh-relay-install-transfers' import { rollbackOrcad, type OrcadRollbackOptions } from './orcad-remote-rollback' import { emptyOrcadActivationRecord, type OrcadActivationRecord } from './orcad-activation-record' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -79,9 +74,6 @@ function scriptHost( ): void { mockExec.mockImplementation(async (_conn, command: string) => { const text = String(command) - if (text.includes('__ORCAD_RECORD_PRESENT__')) { - return `__ORCAD_RECORD_PRESENT__\n${JSON.stringify(record())}` - } if (text.includes('state.tar') && text.includes('test -f') && !text.includes('tar -C')) { return 'PRESENT' } @@ -100,7 +92,7 @@ function scriptHost( log.push(`launch:${text.includes(ACTIVE) ? ACTIVE : TARGET}`) return '9999' } - if (text.startsWith('head -c ') && text.includes('.orcad-readiness')) { + if (text.startsWith('cat ') && text.includes('.orcad-readiness')) { if (overrides.readinessAtMs !== undefined && Date.now() < overrides.readinessAtMs) { return '' } @@ -177,7 +169,7 @@ describe('rollbackOrcad', () => { code: 'orcad_rollback_orphans_live_terminals' }) expect(log).toEqual([]) - expect(vi.mocked(writeAtomicOrcadRemoteRecord)).not.toHaveBeenCalled() + expect(vi.mocked(writeRelayFile)).not.toHaveBeenCalled() }) it('refuses when the snapshot is gone from the host', async () => { @@ -220,7 +212,7 @@ describe('rollbackOrcad', () => { expect(result).toMatchObject({ outcome: 'failed', code: 'orcad_activation_no_readiness' }) // Until the target is proven serving, `active` must still name the version an operator // would have to bring back. - expect(vi.mocked(writeAtomicOrcadRemoteRecord)).not.toHaveBeenCalled() + expect(vi.mocked(writeRelayFile)).not.toHaveBeenCalled() }) it('records the rollback only after the target answers healthy', async () => { @@ -228,9 +220,9 @@ describe('rollbackOrcad', () => { scriptHost(log) await rollbackOrcad(options()) const written = vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.find((call) => String(call[1]).endsWith('orcad-active.json')) - expect(JSON.parse(String(written?.[2]))).toMatchObject({ + .mocked(writeRelayFile) + .mock.calls.find((call) => String(call[2]).endsWith('orcad-active.json')) + expect(JSON.parse(String(written?.[3]))).toMatchObject({ active: TARGET, previous: null, snapshot: null diff --git a/src/main/ssh/orcad-remote-rollback.ts b/src/main/ssh/orcad-remote-rollback.ts index 09dc885d5ae1..412021c2f221 100644 --- a/src/main/ssh/orcad-remote-rollback.ts +++ b/src/main/ssh/orcad-remote-rollback.ts @@ -14,19 +14,26 @@ * failure this is meant to avoid, arrived at from the other side. */ import type { SshConnection } from './ssh-connection' +import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' import { execCommand } from './ssh-relay-deploy-helpers' import { ORCAD_INSTALL_MODEL } from './remote-install-model' import { computeRemoteInstallDir } from './ssh-relay-versioned-install' +import { writeRelayFile } from './ssh-relay-install-transfers' import { RELAY_REMOTE_DIR } from './relay-protocol' import { ORCAD_STATE_SNAPSHOT_DIR, + serializeOrcadActivationRecord, withRolledBackVersion, type OrcadActivationRecord } from './orcad-activation-record' import { assessOrcadRollback, type OrcadTerminalCensus } from './orcad-update-plan' import { evaluateOrcadActivation, type OrcadActivationVerdict } from './orcad-activation-gate' -import { ORCAD_LOG_FILENAME } from './orcad-remote-launch' -import { launchOrcadAndAwaitReadiness } from './orcad-remote-runtime-control' +import { + ORCAD_LOG_FILENAME, + orcadLaunchCommand, + parseOrcadReadinessOutput, + readOrcadReadinessCommand +} from './orcad-remote-launch' import { newestStateMtimeCommand, parseNewestStateMtimeSeconds, @@ -39,7 +46,7 @@ import { parseOrcadStopOutcome, stopOrcadCommand } from './orcad-remote-process-control' -import { writeOrcadActivationRecord } from './orcad-activation-record-store' +import { orcadActivationPath } from './orcad-activation-record-store' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' export type OrcadRollbackOptions = { @@ -65,6 +72,7 @@ export type OrcadRollbackResult = | { outcome: 'refused'; code: string; reason: string } | { outcome: 'failed'; code: string; reason: string } +const READINESS_POLL_MS = 500 const STOP_WAIT_SECONDS = 20 function exec(options: OrcadRollbackOptions, command: string): Promise { @@ -177,14 +185,29 @@ export async function rollbackOrcad(options: OrcadRollbackOptions): Promise new Promise((r) => setTimeout(r, ms))) + let parsed = parseOrcadReadinessOutput('') + while (Date.now() < deadline && parsed.state === 'pending') { + options.signal?.throwIfAborted() + parsed = parseOrcadReadinessOutput( + await exec(options, readOrcadReadinessCommand(options.host, targetDir)) + ) + if (parsed.state === 'pending') { + await sleep(READINESS_POLL_MS) + } + } const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, { buildHash: options.targetBuildHash, fullVersion: safety.target @@ -202,7 +225,13 @@ export async function rollbackOrcad(options: OrcadRollbackOptions): Promise { - return execCommand(target.conn, command, { - wrapCommand: target.host.commandDialect !== 'powershell', - signal - }) -} - -/** Recovery paths must finish even when the request that started them was cancelled. */ -export function withoutAbortSignal( - options: T -): Omit { - const { signal: _signal, ...rest } = options - return rest -} - -export async function launchOrcadAndAwaitReadiness( - target: OrcadRemoteExecTarget & { - readinessTimeoutMs?: number - sleep?: (ms: number) => Promise - }, - spec: OrcadLaunchSpec -): Promise { - await execOrcadRemote(target, orcadLaunchCommand(target.host, spec)) - const deadline = Date.now() + (target.readinessTimeoutMs ?? ORCAD_STARTUP_READINESS_TIMEOUT_MS) - const sleep = target.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) - let last = parseOrcadReadinessOutput('') - while (Date.now() < deadline) { - target.signal?.throwIfAborted() - last = parseOrcadReadinessOutput( - await execOrcadRemote(target, readOrcadReadinessCommand(target.host, spec.remoteInstallDir)) - ) - if (last.state !== 'pending') { - return last - } - await sleep(READINESS_POLL_MS) - } - return last -} From 0a407f1c638e8f7ebcf9de486ce48c4260241c83 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:04 -0700 Subject: [PATCH 16/26] Revert "fix(runtime): fence runtime-environment subscriptions and status probes by identity (#16741 T5-3) (#24421)" This reverts commit ece9e4d2e31b65719c6800208439574189251dc8. --- ...runtime-environment-capability-evidence.ts | 4 - .../ipc/runtime-environment-revision-guard.ts | 8 - .../ipc/runtime-environment-status-owner.ts | 19 +- .../ipc/runtime-environment-status-probe.ts | 42 --- ...time-environment-status-retirement.test.ts | 110 ------- ...-environment-subscription-identity.test.ts | 108 ------- .../ipc/runtime-environment-subscriptions.ts | 268 ------------------ .../runtime-environment-support-routing.ts | 31 +- .../runtime-environment-transport-routing.ts | 46 ++- ...environments-subscription-teardown.test.ts | 9 +- src/main/ipc/runtime-environments.ts | 238 +++++++++++++++- 11 files changed, 298 insertions(+), 585 deletions(-) delete mode 100644 src/main/ipc/runtime-environment-status-probe.ts delete mode 100644 src/main/ipc/runtime-environment-status-retirement.test.ts delete mode 100644 src/main/ipc/runtime-environment-subscription-identity.test.ts delete mode 100644 src/main/ipc/runtime-environment-subscriptions.ts diff --git a/src/main/ipc/runtime-environment-capability-evidence.ts b/src/main/ipc/runtime-environment-capability-evidence.ts index 7dd47712fb21..197ec71f4f84 100644 --- a/src/main/ipc/runtime-environment-capability-evidence.ts +++ b/src/main/ipc/runtime-environment-capability-evidence.ts @@ -64,10 +64,6 @@ export function advanceRuntimeEnvironmentCapabilityIncarnation(environmentId: st state.accepted = null } -export function getRuntimeEnvironmentCapabilityIncarnation(environmentId: string): number { - return stateFor(environmentId).epoch -} - export function applyRuntimeEnvironmentCapabilityVerdict(args: { evidence: RuntimeEnvironmentCapabilityEvidence verdict: RuntimeEnvironmentCapabilityVerdict diff --git a/src/main/ipc/runtime-environment-revision-guard.ts b/src/main/ipc/runtime-environment-revision-guard.ts index 03d6169675d2..af70d5563566 100644 --- a/src/main/ipc/runtime-environment-revision-guard.ts +++ b/src/main/ipc/runtime-environment-revision-guard.ts @@ -15,14 +15,6 @@ export function runtimeEnvironmentRevisionFailure( if (!pairingChanged && !runtimeChanged) { return null } - return runtimeEnvironmentChangedFailure(environment, method, pairingChanged) -} - -export function runtimeEnvironmentChangedFailure( - environment: Pick, - method: string, - pairingChanged = false -): RuntimeRpcResponse { return { id: method, ok: false, diff --git a/src/main/ipc/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environment-status-owner.ts index ec23ebe47bd6..f88741ca9a3c 100644 --- a/src/main/ipc/runtime-environment-status-owner.ts +++ b/src/main/ipc/runtime-environment-status-owner.ts @@ -8,7 +8,6 @@ import { } from '../../shared/runtime-environments' import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' import { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' -import type { RuntimeStatus } from '../../shared/runtime-types' import { RUNTIME_HOST_STATUS_CHANNEL, type RuntimeHostStatusResponse @@ -16,11 +15,9 @@ import { import { applyRuntimeEnvironmentCapabilityVerdict, getAcceptedRuntimeEnvironmentCapabilityOutcome, - getRuntimeEnvironmentCapabilityIncarnation, captureRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' -import { runtimeEnvironmentChangedFailure } from './runtime-environment-revision-guard' export function createRuntimeEnvironmentStatusOwner( userDataPath: string, @@ -37,16 +34,13 @@ export function createRuntimeEnvironmentStatusOwner( return new RuntimeHostStatusOwner({ environmentId: environment.id, pairingRevision: environment.pairingRevision ?? environment.createdAt, - request: async (signal) => { - const incarnation = getRuntimeEnvironmentCapabilityIncarnation(environment.id) - const isCurrent = (): boolean => - getRuntimeEnvironmentCapabilityIncarnation(environment.id) === incarnation + request: (signal) => { evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) - const response = await (transport.isReady() && - getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, pairing, null)?.kind === - 'supported' + return transport.isReady() && + getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, pairing, null)?.kind === + 'supported' ? transport.request(signal) - : sendRemoteRuntimeRequest( + : sendRemoteRuntimeRequest( pairing, 'status.get', undefined, @@ -54,8 +48,7 @@ export function createRuntimeEnvironmentStatusOwner( undefined, signal, ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - )) - return isCurrent() ? response : runtimeEnvironmentChangedFailure(environment, 'status.get') + ) }, verified: (response, active) => { const capable = diff --git a/src/main/ipc/runtime-environment-status-probe.ts b/src/main/ipc/runtime-environment-status-probe.ts deleted file mode 100644 index 54a93822a9e6..000000000000 --- a/src/main/ipc/runtime-environment-status-probe.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import { getPreferredPairingOffer } from '../../shared/runtime-environments' -import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' -import type { RuntimeStatus } from '../../shared/runtime-types' -import { getRuntimeEnvironmentCapabilityIncarnation } from './runtime-environment-capability-evidence' -import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections' -import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' -import { runtimeEnvironmentChangedFailure } from './runtime-environment-revision-guard' -import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics' -import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response' - -export async function getRuntimeEnvironmentStatus( - userDataPath: string, - selector: string, - timeoutMs?: number, - options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true } -): Promise> { - const environment = resolveEnvironment(userDataPath, selector) - if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { - return { - id: 'status.get', - ok: false, - error: { - code: 'runtime_manually_disconnected', - message: 'Runtime environment is manually disconnected.' - } - } - } - const incarnation = getRuntimeEnvironmentCapabilityIncarnation(environment.id) - const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({ - timeoutMs, - ...options - }) - // A retired request must not publish old status or borrow its replacement's diagnostics. - if (getRuntimeEnvironmentCapabilityIncarnation(environment.id) !== incarnation) { - return runtimeEnvironmentChangedFailure(environment, 'status.get') - } - return attachRemoteControlDiagnostics( - withTailscaleHintForResponse(response, getPreferredPairingOffer(environment).endpoint), - environment.id - ) -} diff --git a/src/main/ipc/runtime-environment-status-retirement.test.ts b/src/main/ipc/runtime-environment-status-retirement.test.ts deleted file mode 100644 index 37ebf690a60e..000000000000 --- a/src/main/ipc/runtime-environment-status-retirement.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { - addEnvironmentFromPairingCode, - resolveEnvironment -} from '../../shared/runtime-environment-store' -import { pairingCode } from './runtime-environments-ipc-test-harness' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' -import { - advanceRuntimeEnvironmentCapabilityIncarnation, - resetRuntimeEnvironmentCapabilityEvidence -} from './runtime-environment-capability-evidence' - -const mocks = vi.hoisted(() => ({ - request: vi.fn(), - ensure: vi.fn(), - reconnect: vi.fn(), - diagnostics: vi.fn() -})) -vi.mock('../../shared/remote-runtime-client', () => ({ sendRemoteRuntimeRequest: mocks.request })) -vi.mock('./runtime-environment-request-connections', async () => { - const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') - return withRuntimeStatusOwners({ - ensureRemoteRuntimeSharedControlConnection: mocks.ensure, - reconnectRemoteRuntimeSharedControlConnection: mocks.reconnect, - getRemoteRuntimeSharedControlDiagnostics: mocks.diagnostics, - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: vi.fn() - }) -}) -vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) - -import { getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing' -import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' - -let directory: string -let envId: string -beforeEach(() => { - vi.clearAllMocks() - resetRuntimeEnvironmentCapabilityEvidence() - directory = mkdtempSync(join(tmpdir(), 'orca-status-retirement-')) - envId = addEnvironmentFromPairingCode(directory, { - name: 'Host', - now: 1, - pairingCode: pairingCode(), - connectionDependency: 'ssh-tunnel' - }).id - mocks.diagnostics.mockReturnValue(null) -}) -afterEach(() => { - resetRuntimeEnvironmentStatusOwners() - rmSync(directory, { recursive: true, force: true }) -}) - -function response() { - return { - id: 'status', - ok: true as const, - result: { runtimeId: 'old-host', capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] }, - _meta: { runtimeId: 'old-host' } - } -} - -it.each(['success', 'failure', 'throw'] as const)( - 'fences a retired status probe settling with %s without publishing status or diagnostics', - async (settlement) => { - const pending = Promise.withResolvers() - mocks.request.mockReturnValue(pending.promise) - const probe = getRuntimeEnvironmentStatus(directory, envId) - await vi.waitFor(() => expect(mocks.request).toHaveBeenCalledOnce()) - mocks.diagnostics.mockClear() - advanceRuntimeEnvironmentCapabilityIncarnation(envId) - if (settlement === 'throw') { - pending.reject(new Error('old endpoint offline')) - } else { - pending.resolve( - settlement === 'success' - ? response() - : { - id: 'status', - ok: false, - error: { code: 'offline', message: 'old endpoint' }, - _meta: { runtimeId: 'old-host' } - } - ) - } - await expect(probe).resolves.toMatchObject({ - ok: false, - error: { code: 'runtime_environment_changed' } - }) - expect(resolveEnvironment(directory, envId).runtimeId).toBeNull() - expect(mocks.ensure).not.toHaveBeenCalled() - expect(mocks.reconnect).not.toHaveBeenCalled() - expect(mocks.diagnostics).not.toHaveBeenCalled() - } -) - -it('does not fence another environment or a fresh probe after retirement', async () => { - mocks.request.mockImplementation(async () => { - advanceRuntimeEnvironmentCapabilityIncarnation('other') - return response() - }) - await expect(getRuntimeEnvironmentStatus(directory, envId)).resolves.toMatchObject({ ok: true }) - advanceRuntimeEnvironmentCapabilityIncarnation(envId) - await expect(getRuntimeEnvironmentStatus(directory, envId)).resolves.toMatchObject({ ok: true }) - expect(resolveEnvironment(directory, envId).runtimeId).toBe('old-host') - expect(mocks.ensure).toHaveBeenCalledTimes(2) -}) diff --git a/src/main/ipc/runtime-environment-subscription-identity.test.ts b/src/main/ipc/runtime-environment-subscription-identity.test.ts deleted file mode 100644 index ce1a72d89455..000000000000 --- a/src/main/ipc/runtime-environment-subscription-identity.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' -import type { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' - -const mocks = vi.hoisted(() => ({ handle: vi.fn(), on: vi.fn(), subscribe: vi.fn() })) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle, on: mocks.on } })) -vi.mock('../../shared/runtime-environment-store', () => ({ - resolveEnvironment: () => ({ id: 'env', pairingRevision: 1, createdAt: 1 }) -})) -vi.mock('./runtime-environment-transport-routing', () => ({ - subscribeRuntimeEnvironment: mocks.subscribe -})) -import { - closeSubscriptionsForEnvironment, - registerRuntimeEnvironmentSubscriptions -} from './runtime-environment-subscriptions' - -type Callbacks = Parameters[5] -const sender = { - id: 1, - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - removeListener: vi.fn() -} -const args = { selector: 'env', method: 'terminal.multiplex', subscriptionId: 'reused-id' } -function handler(channel: string) { - return mocks.handle.mock.calls.find(([name]) => name === channel)![1] -} -const open = () => handler('runtimeEnvironments:subscribe')({ sender }, args) -const unsubscribe = () => - handler('runtimeEnvironments:unsubscribe')({ sender }, { subscriptionId: args.subscriptionId }) -const connection = (): RemoteRuntimeSubscription => ({ - requestId: 'request', - sendBinary: vi.fn(() => true), - close: vi.fn() -}) - -beforeEach(() => { - vi.clearAllMocks() - mocks.subscribe.mockReset() - registerRuntimeEnvironmentSubscriptions(() => '/profile') -}) -afterEach(() => closeSubscriptionsForEnvironment('env')) - -it('reserves an ID while setup is pending so a second open cannot take its ownership', async () => { - const gate = Promise.withResolvers() - mocks.subscribe.mockReturnValue(gate.promise) - const first = open() - await expect(open()).rejects.toThrow('already exists') - expect(mocks.subscribe).toHaveBeenCalledOnce() - gate.resolve(connection()) - await expect(first).resolves.toMatchObject({ subscriptionId: 'reused-id' }) -}) - -it('releases the reservation after failed setup so an explicit retry can open', async () => { - mocks.subscribe.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(connection()) - await expect(open()).rejects.toThrow('offline') - await expect(open()).resolves.toMatchObject({ subscriptionId: 'reused-id' }) -}) - -it('does not let callbacks from an old subscription publish to or remove its replacement', async () => { - const callbacks: Callbacks[] = [] - const currentChecks: (() => boolean)[] = [] - const connections: RemoteRuntimeSubscription[] = [] - mocks.subscribe.mockImplementation( - async (...parameters: Parameters) => { - callbacks.push(parameters[5]) - currentChecks.push(parameters[6]!) - const value = connection() - connections.push(value) - return value - } - ) - await open() - expect(unsubscribe()).toEqual({ unsubscribed: true }) - await open() - sender.send.mockClear() - expect(currentChecks[0]()).toBe(false) - expect(currentChecks[1]()).toBe(true) - const payload = { type: 'binary' as const, bytes: new Uint8Array([1]) } - callbacks[0].onEvent(payload) - callbacks[0].onEvent({ type: 'close' }) - callbacks[0].onClose() - expect(sender.send).not.toHaveBeenCalled() - callbacks[1].onEvent(payload) - expect(sender.send).toHaveBeenCalledWith('runtimeEnvironments:subscriptionEvent', { - subscriptionId: 'reused-id', - ...payload - }) - expect(unsubscribe()).toEqual({ unsubscribed: true }) - expect(connections[1].close).toHaveBeenCalledOnce() -}) - -it('does not retain a subscription that closes before setup resolves', async () => { - const value = connection() - mocks.subscribe.mockImplementationOnce( - async (...parameters: Parameters) => { - parameters[5].onEvent({ type: 'close' }) - parameters[5].onClose() - return value - } - ) - await expect(open()).rejects.toThrow('closed during setup') - expect(value.close).toHaveBeenCalledOnce() - mocks.subscribe.mockResolvedValueOnce(connection()) - await expect(open()).resolves.toMatchObject({ subscriptionId: 'reused-id' }) -}) diff --git a/src/main/ipc/runtime-environment-subscriptions.ts b/src/main/ipc/runtime-environment-subscriptions.ts deleted file mode 100644 index f1fdd9aaa3a7..000000000000 --- a/src/main/ipc/runtime-environment-subscriptions.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { ipcMain } from 'electron' -import { randomUUID } from 'node:crypto' -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' -import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' -import { getRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' -import { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' - -type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { - token: symbol - environmentId: string - ownerWebContentsId: number - removeDestroyedListener: () => void - notifyClosed: () => void -} -const remoteRuntimeSubscriptions = new Map() -// Why: an id is claimed while its socket opens, so a duplicate or a late callback cannot cross over. -const pendingSubscriptions = new Map() - -export function closeSubscriptionsForEnvironment(environmentId: string): void { - // Why: removed runtimes must not retain terminal/browser WebSockets until renderer teardown. - for (const [subscriptionId, subscription] of remoteRuntimeSubscriptions) { - if (subscription.environmentId !== environmentId) { - continue - } - remoteRuntimeSubscriptions.delete(subscriptionId) - // Why: one failing teardown must not abandon this environment's other - // sockets -- that strands exactly the dead handles this sweep exists to - // retire. Guard the two steps independently so neither can skip the other, - // and so the isolation stays structural rather than resting on a claim that - // nothing inside notifyClosed will ever throw. - try { - subscription.close() - } catch (error) { - console.warn('[runtime-environments] subscription close failed during retirement:', error) - } - try { - // Why: a shared-control logical close never calls back, so notify directly. - subscription.notifyClosed() - } catch (error) { - console.warn('[runtime-environments] subscription close notice failed:', error) - } - } -} -export function registerRuntimeEnvironmentSubscriptions(getUserDataPath: () => string): void { - ipcMain.handle( - 'runtimeEnvironments:subscribe', - async ( - event, - args: { - selector: string - method: string - params?: unknown - timeoutMs?: number - subscriptionId?: string - expectedEnvironmentPairingRevision?: number - expectedEnvironmentRuntimeId?: string - } - ): Promise<{ subscriptionId: string; requestId: string }> => { - const subscriptionId = - typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0 - ? args.subscriptionId - : randomUUID() - if ( - remoteRuntimeSubscriptions.has(subscriptionId) || - pendingSubscriptions.has(subscriptionId) - ) { - throw new Error('Runtime environment subscription id already exists') - } - const environment = resolveEnvironment(getUserDataPath(), args.selector) - if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { - throw new Error('runtime_manually_disconnected') - } - const pairingRevision = environment.pairingRevision ?? environment.createdAt - if ( - args.expectedEnvironmentPairingRevision !== undefined && - pairingRevision !== args.expectedEnvironmentPairingRevision - ) { - throw new Error('Runtime environment pairing changed; refresh and try again') - } - if ( - args.expectedEnvironmentRuntimeId !== undefined && - environment.runtimeId !== args.expectedEnvironmentRuntimeId - ) { - throw new Error('Runtime environment identity changed; refresh and try again') - } - const transportGeneration = getRuntimeEnvironmentTransportGeneration(environment.id) - const token = Symbol(subscriptionId) - let closed = false - const ownsId = (): boolean => - pendingSubscriptions.get(subscriptionId) === token || - remoteRuntimeSubscriptions.get(subscriptionId)?.token === token - const releasePending = (): void => { - if (pendingSubscriptions.get(subscriptionId) === token) { - pendingSubscriptions.delete(subscriptionId) - } - } - const transportIsCurrent = (): boolean => - getRuntimeEnvironmentTransportGeneration(environment.id) === transportGeneration - const sender = event.sender - const ownerWebContentsId = sender.id - let senderDestroyed = sender.isDestroyed() - let subscription: RemoteRuntimeSubscription | null = null - let destroyedListenerAttached = false - const removeDestroyedListener = (): void => { - if (!destroyedListenerAttached) { - return - } - destroyedListenerAttached = false - sender.removeListener('destroyed', closeSubscription) - } - const closeSubscription = (): void => { - senderDestroyed = true - closed = true - releasePending() - const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null - if (retained?.token === token) { - remoteRuntimeSubscriptions.delete(subscriptionId) - retained.close() - return - } - removeDestroyedListener() - subscription?.close() - } - // Why: the renderer treats close as terminal and drops its handle, so send it once. - // Latch before sending so a re-entrant call cannot duplicate it, and never - // throw: a dying renderer must not abort its siblings' retirement. - let closeNotified = false - const notifyClosed = (): void => { - closed = true - if (closeNotified || sender.isDestroyed()) { - return - } - closeNotified = true - try { - sender.send('runtimeEnvironments:subscriptionEvent', { subscriptionId, type: 'close' }) - } catch { - // The renderer is gone; there is no one left to tell. - } - } - pendingSubscriptions.set(subscriptionId, token) - try { - sender.once('destroyed', closeSubscription) - destroyedListenerAttached = true - subscription = await subscribeRuntimeEnvironment( - getUserDataPath(), - environment.id, - args.method, - args.params, - args.timeoutMs, - { - onEvent: (payload) => { - if (payload.type === 'close') { - // Why: retirement advances the generation before closing, so gating - // close on it stranded the renderer with a dead subscription. - if (ownsId()) { - notifyClosed() - } - return - } - if (!closed && ownsId() && transportIsCurrent() && !sender.isDestroyed()) { - sender.send('runtimeEnvironments:subscriptionEvent', { - subscriptionId, - ...payload - }) - } - }, - onClose: () => { - closed = true - releasePending() - removeDestroyedListener() - const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null - if (retained?.token === token) { - remoteRuntimeSubscriptions.delete(subscriptionId) - } - } - }, - () => !closed && ownsId() && transportIsCurrent() - ) - } catch (error) { - closed = true - releasePending() - removeDestroyedListener() - throw error - } - releasePending() - let pairingIsCurrent = false - try { - const currentEnvironment = resolveEnvironment(getUserDataPath(), environment.id) - pairingIsCurrent = - (currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === pairingRevision - } catch { - pairingIsCurrent = false - } - if (!transportIsCurrent() || !pairingIsCurrent) { - removeDestroyedListener() - subscription.close() - throw new Error('Runtime environment pairing changed; refresh and try again') - } - if (senderDestroyed || sender.isDestroyed()) { - removeDestroyedListener() - subscription.close() - return { subscriptionId, requestId: subscription.requestId } - } - if (closed) { - removeDestroyedListener() - subscription.close() - throw new Error('Runtime environment subscription closed during setup') - } - remoteRuntimeSubscriptions.set(subscriptionId, { - token, - requestId: subscription.requestId, - environmentId: environment.id, - ownerWebContentsId, - removeDestroyedListener, - notifyClosed, - sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, - close: () => { - closed = true - removeDestroyedListener() - subscription?.close() - } - }) - return { subscriptionId, requestId: subscription.requestId } - } - ) - ipcMain.handle( - 'runtimeEnvironments:unsubscribe', - (event, args: { subscriptionId: string }): { unsubscribed: boolean } => { - const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) - if (!subscription || subscription.ownerWebContentsId !== event.sender.id) { - return { unsubscribed: false } - } - remoteRuntimeSubscriptions.delete(args.subscriptionId) - subscription.close() - return { unsubscribed: true } - } - ) - ipcMain.on( - 'runtimeEnvironments:subscriptionBinary', - (event, args: { subscriptionId?: unknown; bytes?: unknown }) => { - if (typeof args.subscriptionId !== 'string') { - return - } - const bytes = toBinaryPayload(args.bytes) - if (!bytes) { - return - } - const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) - if (subscription?.ownerWebContentsId === event.sender.id) { - subscription.sendBinary(bytes) - } - } - ) -} - -function toBinaryPayload(value: unknown): Uint8Array | null { - if (value instanceof Uint8Array) { - return value - } - if (value instanceof ArrayBuffer) { - return new Uint8Array(value) - } - if (ArrayBuffer.isView(value)) { - return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) - } - return null -} diff --git a/src/main/ipc/runtime-environment-support-routing.ts b/src/main/ipc/runtime-environment-support-routing.ts index f1a846d988e3..ae68b3d35f8f 100644 --- a/src/main/ipc/runtime-environment-support-routing.ts +++ b/src/main/ipc/runtime-environment-support-routing.ts @@ -1,4 +1,3 @@ -import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { PairingOffer } from '../../shared/pairing' import type { @@ -19,10 +18,7 @@ import { isRuntimeEnvironmentCapabilityOutcomeCurrent, type RuntimeEnvironmentCapabilityOutcome } from './runtime-environment-capability-evidence' -import { - runtimeEnvironmentChangedFailure, - runtimeEnvironmentRevisionFailure -} from './runtime-environment-revision-guard' +import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' import { supportsSharedControl } from './runtime-environment-shared-control-support' import { sendRemoteRuntimeRequestAbortable, @@ -241,6 +237,21 @@ export async function routeRuntimeEnvironmentSubscriptionBySupport { + return { + id: method, + ok: false, + error: { + code: 'runtime_environment_changed', + message: 'Runtime environment pairing changed; refresh and try again' + }, + _meta: { runtimeId: environment.runtimeId } + } +} + function subscriptionCallbacks( args: Pick< Parameters[0], @@ -272,13 +283,3 @@ function subscriptionCallbacks( } } } - -export function shouldUseSharedControlEnvelope( - method: string, - params: unknown, - envelope: RuntimeOrchestrationEnvelope | undefined -): RuntimeOrchestrationEnvelope | undefined { - return envelope && method.startsWith('orchestration.') && !isOrchestrationMutation(method, params) - ? envelope - : undefined -} diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 44430ec4f8f3..fd60ef33fcda 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,27 +1,32 @@ -import { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe' import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' +import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' import type { RuntimeOrchestrationEnvelope, RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../shared/runtime-types' import { subscribeRemoteRuntimeRequest, type RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint' import { enqueueRuntimeCall } from './runtime-environment-call-queue' +import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections' import { sendRemoteRuntimeConnectionRequestAbortable, sendRemoteRuntimeRequestAbortable } from './runtime-environment-abortable-requests' +import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics' + +import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' +import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response' import { resetSharedControlSupport } from './runtime-environment-shared-control-support' import { executeSupportRoutedCall, - shouldUseSharedControlEnvelope, shouldRouteCallBySupport, shouldRouteSubscriptionBySupport, subscribeSupportRoutedRuntimeEnvironment @@ -31,7 +36,32 @@ const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000 export { resetSharedControlSupport } -export { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe' +export async function getRuntimeEnvironmentStatus( + userDataPath: string, + selector: string, + timeoutMs?: number, + options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true } +): Promise> { + const environment = resolveEnvironment(userDataPath, selector) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + return { + id: 'status.get', + ok: false, + error: { + code: 'runtime_manually_disconnected', + message: 'Runtime environment is manually disconnected.' + } + } + } + const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({ + timeoutMs, + ...options + }) + return attachRemoteControlDiagnostics( + withTailscaleHintForResponse(response, getPreferredPairingOffer(environment).endpoint), + environment.id + ) +} export async function callRuntimeEnvironment( userDataPath: string, @@ -231,3 +261,13 @@ function markEnvironmentUsedFromResponse( function shouldUseCachedRequestConnection(method: string): boolean { return method === 'terminal.send' || method === 'terminal.updateViewport' } + +function shouldUseSharedControlEnvelope( + method: string, + params: unknown, + envelope: RuntimeOrchestrationEnvelope | undefined +): RuntimeOrchestrationEnvelope | undefined { + return envelope && method.startsWith('orchestration.') && !isOrchestrationMutation(method, params) + ? envelope + : undefined +} diff --git a/src/main/ipc/runtime-environments-subscription-teardown.test.ts b/src/main/ipc/runtime-environments-subscription-teardown.test.ts index 4d10253c8e8c..afb1adf457a8 100644 --- a/src/main/ipc/runtime-environments-subscription-teardown.test.ts +++ b/src/main/ipc/runtime-environments-subscription-teardown.test.ts @@ -485,7 +485,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(deliveredCloses).toEqual([]) }) - it('fences late payloads and duplicate close after full retirement', async () => { + it('suppresses stale payloads from a retired transport but never re-sends its close', async () => { registerRuntimeEnvironmentHandlers(store as never) let transportCallbacks: { onResponse: (response: Record) => void @@ -533,16 +533,11 @@ describe('registerRuntimeEnvironmentHandlers', () => { } ) - await invalidateRuntimeEnvironmentTransport(added.environment.id) + invalidateRuntimeEnvironmentTransport(added.environment.id) expect(retirePairedRuntimeBrowserClientHostEnvironmentMock).toHaveBeenCalledWith( added.environment.id, expect.objectContaining({ message: 'Runtime environment transport was invalidated' }) ) - expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id) - expect(senderSend).toHaveBeenCalledWith('runtimeEnvironments:subscriptionEvent', { - subscriptionId: 'multiplex-stale', - type: 'close' - }) senderSend.mockClear() // A late frame from the retired socket must not reach the renderer... transportCallbacks!.onResponse({ diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index bbb6bd09a657..6d16315b3a70 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -1,5 +1,7 @@ import { app, ipcMain } from 'electron' -import { listEnvironments } from '../../shared/runtime-environment-store' +import { randomUUID } from 'node:crypto' +import { listEnvironments, resolveEnvironment } from '../../shared/runtime-environment-store' +import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import type { Store } from '../persistence' import { isRuntimeEnvironmentManuallyDisconnected, @@ -11,19 +13,53 @@ import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections' import { registerRuntimeEnvironmentRecoveryHandler } from './runtime-environment-recovery-handler' -import { advanceRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' -import { resetSharedControlSupport } from './runtime-environment-transport-routing' import { - closeSubscriptionsForEnvironment, - registerRuntimeEnvironmentSubscriptions -} from './runtime-environment-subscriptions' + advanceRuntimeEnvironmentTransportGeneration, + getRuntimeEnvironmentTransportGeneration +} from './runtime-environment-transport-generation' +import { + resetSharedControlSupport, + subscribeRuntimeEnvironment +} from './runtime-environment-transport-routing' import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels' import { retirePairedRuntimeBrowserClientHostEnvironment } from '../browser/paired-runtime-browser-client-host-runtime' import { registerRuntimeEnvironmentBrowserClientHostHandler } from './runtime-environment-browser-client-host-handler' import { advanceRuntimeEnvironmentCapabilityIncarnation } from './runtime-environment-capability-evidence' +type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { + environmentId: string + ownerWebContentsId: number + removeDestroyedListener: () => void + notifyClosed: () => void +} +const remoteRuntimeSubscriptions = new Map() const getUserDataPath = (): string => app.getPath('userData') +function closeSubscriptionsForEnvironment(environmentId: string): void { + // Why: removed runtimes must not retain terminal/browser WebSockets until renderer teardown. + for (const [subscriptionId, subscription] of remoteRuntimeSubscriptions) { + if (subscription.environmentId !== environmentId) { + continue + } + remoteRuntimeSubscriptions.delete(subscriptionId) + // Why: one failing teardown must not abandon this environment's other + // sockets -- that strands exactly the dead handles this sweep exists to + // retire. Guard the two steps independently so neither can skip the other, + // and so the isolation stays structural rather than resting on a claim that + // nothing inside notifyClosed will ever throw. + try { + subscription.close() + } catch (error) { + console.warn('[runtime-environments] subscription close failed during retirement:', error) + } + try { + // Why: a shared-control logical close never calls back, so notify directly. + subscription.notifyClosed() + } catch (error) { + console.warn('[runtime-environments] subscription close notice failed:', error) + } + } +} /** Returns once the environment's client-hosted browser pages have been released. */ export function invalidateRuntimeEnvironmentTransport(environmentId: string): Promise { // Why: a same-id re-pair must retire every transport that still authenticates as the old peer. @@ -67,5 +103,193 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id).activate() } } - registerRuntimeEnvironmentSubscriptions(getUserDataPath) + ipcMain.handle( + 'runtimeEnvironments:subscribe', + async ( + event, + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + subscriptionId?: string + expectedEnvironmentPairingRevision?: number + expectedEnvironmentRuntimeId?: string + } + ): Promise<{ subscriptionId: string; requestId: string }> => { + const subscriptionId = + typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0 + ? args.subscriptionId + : randomUUID() + if (remoteRuntimeSubscriptions.has(subscriptionId)) { + throw new Error('Runtime environment subscription id already exists') + } + const environment = resolveEnvironment(getUserDataPath(), args.selector) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + throw new Error('runtime_manually_disconnected') + } + const pairingRevision = environment.pairingRevision ?? environment.createdAt + if ( + args.expectedEnvironmentPairingRevision !== undefined && + pairingRevision !== args.expectedEnvironmentPairingRevision + ) { + throw new Error('Runtime environment pairing changed; refresh and try again') + } + if ( + args.expectedEnvironmentRuntimeId !== undefined && + environment.runtimeId !== args.expectedEnvironmentRuntimeId + ) { + throw new Error('Runtime environment identity changed; refresh and try again') + } + const transportGeneration = getRuntimeEnvironmentTransportGeneration(environment.id) + const transportIsCurrent = (): boolean => + getRuntimeEnvironmentTransportGeneration(environment.id) === transportGeneration + const sender = event.sender + const ownerWebContentsId = sender.id + let senderDestroyed = sender.isDestroyed() + let subscription: RemoteRuntimeSubscription | null = null + let destroyedListenerAttached = false + const removeDestroyedListener = (): void => { + if (!destroyedListenerAttached) { + return + } + destroyedListenerAttached = false + sender.removeListener('destroyed', closeSubscription) + } + const closeSubscription = (): void => { + senderDestroyed = true + const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null + remoteRuntimeSubscriptions.delete(subscriptionId) + if (retained) { + retained.close() + return + } + removeDestroyedListener() + subscription?.close() + } + // Why: the renderer treats close as terminal and drops its handle, so send it once. + // Latch before sending so a re-entrant call cannot duplicate it, and never + // throw: a dying renderer must not abort its siblings' retirement. + let closeNotified = false + const notifyClosed = (): void => { + if (closeNotified || sender.isDestroyed()) { + return + } + closeNotified = true + try { + sender.send('runtimeEnvironments:subscriptionEvent', { subscriptionId, type: 'close' }) + } catch { + // The renderer is gone; there is no one left to tell. + } + } + sender.once('destroyed', closeSubscription) + destroyedListenerAttached = true + try { + subscription = await subscribeRuntimeEnvironment( + getUserDataPath(), + environment.id, + args.method, + args.params, + args.timeoutMs, + { + onEvent: (payload) => { + if (payload.type === 'close') { + // Why: retirement advances the generation before closing, so gating + // close on it stranded the renderer with a dead subscription. + notifyClosed() + return + } + if (transportIsCurrent() && !sender.isDestroyed()) { + sender.send('runtimeEnvironments:subscriptionEvent', { + subscriptionId, + ...payload + }) + } + }, + onClose: () => { + const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null + retained?.removeDestroyedListener() + remoteRuntimeSubscriptions.delete(subscriptionId) + } + }, + transportIsCurrent + ) + } catch (error) { + removeDestroyedListener() + throw error + } + let pairingIsCurrent = false + try { + const currentEnvironment = resolveEnvironment(getUserDataPath(), environment.id) + pairingIsCurrent = + (currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === pairingRevision + } catch { + pairingIsCurrent = false + } + if (!transportIsCurrent() || !pairingIsCurrent) { + removeDestroyedListener() + subscription.close() + throw new Error('Runtime environment pairing changed; refresh and try again') + } + if (senderDestroyed || sender.isDestroyed()) { + removeDestroyedListener() + subscription.close() + return { subscriptionId, requestId: subscription.requestId } + } + remoteRuntimeSubscriptions.set(subscriptionId, { + requestId: subscription.requestId, + environmentId: environment.id, + ownerWebContentsId, + removeDestroyedListener, + notifyClosed, + sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, + close: () => { + removeDestroyedListener() + subscription?.close() + } + }) + return { subscriptionId, requestId: subscription.requestId } + } + ) + ipcMain.handle( + 'runtimeEnvironments:unsubscribe', + (event, args: { subscriptionId: string }): { unsubscribed: boolean } => { + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (!subscription || subscription.ownerWebContentsId !== event.sender.id) { + return { unsubscribed: false } + } + remoteRuntimeSubscriptions.delete(args.subscriptionId) + subscription.close() + return { unsubscribed: true } + } + ) + ipcMain.on( + 'runtimeEnvironments:subscriptionBinary', + (event, args: { subscriptionId?: unknown; bytes?: unknown }) => { + if (typeof args.subscriptionId !== 'string') { + return + } + const bytes = toBinaryPayload(args.bytes) + if (!bytes) { + return + } + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (subscription?.ownerWebContentsId === event.sender.id) { + subscription.sendBinary(bytes) + } + } + ) +} + +function toBinaryPayload(value: unknown): Uint8Array | null { + if (value instanceof Uint8Array) { + return value + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value) + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + } + return null } From 93517f0ea3adbd389f8bcf4f91641a622765d533 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:04 -0700 Subject: [PATCH 17/26] Revert "feat(orcad): migration manifest and dormant-state contracts (#16741 T6-7) (#24422)" This reverts commit 3fbdaba262138e9f8c46db92d87b06b929c11e13. --- src/shared/orcad-migration-catalog-state.ts | 92 ----- .../orcad-migration-client-state-parsing.ts | 239 ----------- ...migration-client-state-value-validation.ts | 80 ---- src/shared/orcad-migration-client-state.ts | 149 ------- ...cad-migration-dormant-automation-fields.ts | 101 ----- ...migration-dormant-automation-validation.ts | 240 ----------- ...gration-dormant-session-validation.test.ts | 177 -------- ...ad-migration-dormant-session-validation.ts | 271 ------------- ...igration-dormant-state-entry-validation.ts | 203 ---------- ...rcad-migration-dormant-state-validation.ts | 184 --------- ...rcad-migration-dormant-value-validation.ts | 100 ----- .../orcad-migration-manifest-contract.test.ts | 124 ------ src/shared/orcad-migration-manifest-fields.ts | 62 --- .../orcad-migration-manifest-validation.ts | 277 ------------- src/shared/orcad-migration-manifest.test.ts | 378 ------------------ src/shared/orcad-migration-manifest.ts | 180 --------- src/shared/orcad-migration-preflight.ts | 83 ---- src/shared/orcad-migration-scrollback.test.ts | 134 ------- src/shared/orcad-migration-scrollback.ts | 187 --------- ...igration-staged-catalog-validation.test.ts | 43 -- ...cad-migration-staged-catalog-validation.ts | 55 --- 21 files changed, 3359 deletions(-) delete mode 100644 src/shared/orcad-migration-catalog-state.ts delete mode 100644 src/shared/orcad-migration-client-state-parsing.ts delete mode 100644 src/shared/orcad-migration-client-state-value-validation.ts delete mode 100644 src/shared/orcad-migration-client-state.ts delete mode 100644 src/shared/orcad-migration-dormant-automation-fields.ts delete mode 100644 src/shared/orcad-migration-dormant-automation-validation.ts delete mode 100644 src/shared/orcad-migration-dormant-session-validation.test.ts delete mode 100644 src/shared/orcad-migration-dormant-session-validation.ts delete mode 100644 src/shared/orcad-migration-dormant-state-entry-validation.ts delete mode 100644 src/shared/orcad-migration-dormant-state-validation.ts delete mode 100644 src/shared/orcad-migration-dormant-value-validation.ts delete mode 100644 src/shared/orcad-migration-manifest-contract.test.ts delete mode 100644 src/shared/orcad-migration-manifest-fields.ts delete mode 100644 src/shared/orcad-migration-manifest-validation.ts delete mode 100644 src/shared/orcad-migration-manifest.test.ts delete mode 100644 src/shared/orcad-migration-manifest.ts delete mode 100644 src/shared/orcad-migration-preflight.ts delete mode 100644 src/shared/orcad-migration-scrollback.test.ts delete mode 100644 src/shared/orcad-migration-scrollback.ts delete mode 100644 src/shared/orcad-migration-staged-catalog-validation.test.ts delete mode 100644 src/shared/orcad-migration-staged-catalog-validation.ts diff --git a/src/shared/orcad-migration-catalog-state.ts b/src/shared/orcad-migration-catalog-state.ts deleted file mode 100644 index 9878978cd24e..000000000000 --- a/src/shared/orcad-migration-catalog-state.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - normalizeOrcadMigrationImportReceipts, - type OrcadMigrationCatalogAbortResult, - type OrcadMigrationCatalogState, - type OrcadMigrationManifest -} from './orcad-migration-manifest' -import { parseOrcadMigrationSnapshotUploadStates } from './orcad-migration-scrollback' - -export function parseOrcadMigrationCatalogState( - value: unknown, - manifest: OrcadMigrationManifest -): OrcadMigrationCatalogState { - const record = requireRecord(value) - if ( - record.migrationId !== manifest.migrationId || - record.manifestSha256 !== manifest.manifestSha256 - ) { - throw new Error('orcad_migration_catalog_state_identity_mismatch') - } - if (record.state === 'absent') { - return { state: 'absent', ...migrationIdentity(manifest) } - } - if (record.state === 'staged') { - const stagedAt = requireDate(record.stagedAt) - const snapshotUploads = parseOrcadMigrationSnapshotUploadStates( - record.snapshotUploads, - manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] - ) - return { - state: 'staged', - ...migrationIdentity(manifest), - stagedAt, - ...(snapshotUploads ? { snapshotUploads } : {}) - } - } - if (record.state === 'committed') { - const receipt = normalizeOrcadMigrationImportReceipts([record.receipt])[0] - if ( - !receipt || - receipt.migrationId !== manifest.migrationId || - receipt.manifestSha256 !== manifest.manifestSha256 - ) { - throw new Error('orcad_migration_catalog_state_receipt_invalid') - } - return { state: 'committed', ...migrationIdentity(manifest), receipt } - } - throw new Error('orcad_migration_catalog_state_invalid') -} - -export function parseOrcadMigrationCatalogAbortResult( - value: unknown, - manifest: OrcadMigrationManifest -): OrcadMigrationCatalogAbortResult { - const state = parseOrcadMigrationCatalogState(value, manifest) - const record = requireRecord(value) - if (typeof record.aborted !== 'boolean') { - throw new Error('orcad_migration_catalog_abort_result_invalid') - } - if ( - record.durableAbsent !== undefined && - (record.durableAbsent !== true || state.state !== 'absent') - ) { - throw new Error('orcad_migration_catalog_abort_durability_invalid') - } - return { - ...state, - aborted: record.aborted, - ...(record.durableAbsent === true ? { durableAbsent: true as const } : {}) - } -} - -function migrationIdentity(manifest: OrcadMigrationManifest) { - return { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } -} - -function requireRecord(value: unknown): Record { - if (!isRecord(value)) { - throw new Error('orcad_migration_catalog_state_invalid') - } - return value -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function requireDate(value: unknown): string { - if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) { - throw new Error('orcad_migration_catalog_state_staged_at_invalid') - } - return value -} diff --git a/src/shared/orcad-migration-client-state-parsing.ts b/src/shared/orcad-migration-client-state-parsing.ts deleted file mode 100644 index 708e4e78e302..000000000000 --- a/src/shared/orcad-migration-client-state-parsing.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { parseHostStableKey } from './automation-owner-key' -import { - MAX_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS, - type ClientHostedBrowserCloseIntent -} from './client-hosted-browser-close-intent' -import type { - OrcadMigrationClientHostedBrowserCloseIntent, - OrcadMigrationUiRoutingState -} from './orcad-migration-client-state' -import type { PersistedMobileClientTabSelections } from './persisted-state-types' -import type { ManualRepoOrderEntry, WorkspaceHostOrder } from './ui-chrome-types' -import type { SavedPortForward } from './ssh-types' -import { - isRecord, - isWorkspaceHostId, - isWorkspaceHostScope, - nullableString, - parseSavedPortForwards as parseSavedPortForwardsValue, - parseStringArray, - requiredRecord -} from './orcad-migration-client-state-value-validation' - -export const MAX_ORCAD_MIGRATION_CLIENT_SELECTIONS = 4_096 -export const MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES = 16_384 -export const MAX_ORCAD_MIGRATION_SAVED_PORT_FORWARDS = 256 -export const MAX_ORCAD_MIGRATION_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS = - MAX_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS - -export function parseClientHostedBrowserCloseIntents( - value: unknown -): OrcadMigrationClientHostedBrowserCloseIntent[] { - if ( - !Array.isArray(value) || - value.length > MAX_ORCAD_MIGRATION_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS - ) { - throw new Error('orcad_migration_dormant_client_close_intents_invalid') - } - const seen = new Set() - return value.map((entry) => { - const record = requiredRecord(entry, 'orcad_migration_dormant_client_close_intent_invalid') - if ( - typeof record.sourceEnvironmentId !== 'string' || - !record.sourceEnvironmentId || - record.sourceEnvironmentId.length > 256 - ) { - throw new Error('orcad_migration_dormant_client_close_intent_environment_invalid') - } - if (typeof record.browserPageId !== 'string' || !record.browserPageId) { - throw new Error('orcad_migration_dormant_client_close_intent_page_invalid') - } - if (typeof record.worktreeId !== 'string' || !record.worktreeId) { - throw new Error('orcad_migration_dormant_client_close_intent_worktree_invalid') - } - if (!Number.isSafeInteger(record.closedAt) || Number(record.closedAt) < 0) { - throw new Error('orcad_migration_dormant_client_close_intent_timestamp_invalid') - } - const parsed = { - sourceEnvironmentId: record.sourceEnvironmentId, - browserPageId: record.browserPageId, - worktreeId: record.worktreeId, - closedAt: Number(record.closedAt) - } satisfies OrcadMigrationClientHostedBrowserCloseIntent - const key = `${parsed.sourceEnvironmentId}\0${parsed.browserPageId}\0${parsed.worktreeId}` - if (seen.has(key)) { - throw new Error('orcad_migration_dormant_client_close_intent_duplicate') - } - seen.add(key) - return parsed - }) -} - -export function parseMobileSelections(value: unknown): PersistedMobileClientTabSelections { - const record = requiredRecord(value, 'orcad_migration_dormant_mobile_selections_invalid') - const result: PersistedMobileClientTabSelections = {} - let count = 0 - for (const [deviceId, byWorktree] of Object.entries(record)) { - if (!deviceId || !isRecord(byWorktree)) { - throw new Error('orcad_migration_dormant_mobile_selections_invalid') - } - const entries: PersistedMobileClientTabSelections[string] = {} - for (const [worktreeId, rawSelection] of Object.entries(byWorktree)) { - if (!worktreeId || !isRecord(rawSelection)) { - throw new Error('orcad_migration_dormant_mobile_selection_invalid') - } - const activeTabId = nullableString(rawSelection.activeTabId) - const activeGroupId = nullableString(rawSelection.activeGroupId) - const activeTabIdByGroupId = requiredRecord( - rawSelection.activeTabIdByGroupId, - 'orcad_migration_dormant_mobile_selection_tabs_invalid' - ) - const tabs: Record = {} - for (const [groupId, tabId] of Object.entries(activeTabIdByGroupId)) { - if (!groupId || typeof tabId !== 'string' || !tabId) { - throw new Error('orcad_migration_dormant_mobile_selection_tabs_invalid') - } - tabs[groupId] = tabId - } - if (!activeTabId && !activeGroupId && Object.keys(tabs).length === 0) { - continue - } - entries[worktreeId] = { activeTabId, activeGroupId, activeTabIdByGroupId: tabs } - count += 1 - if (count > MAX_ORCAD_MIGRATION_CLIENT_SELECTIONS) { - throw new Error('orcad_migration_dormant_mobile_selections_too_many') - } - } - if (Object.keys(entries).length > 0) { - result[deviceId] = entries - } - } - return result -} - -export function parseUiRouting(value: unknown): OrcadMigrationUiRoutingState { - const record = requiredRecord(value, 'orcad_migration_dormant_ui_routing_invalid') - const result: OrcadMigrationUiRoutingState = {} - if ('lastActiveRepoId' in record) { - result.lastActiveRepoId = nullableString(record.lastActiveRepoId) - } - if ('lastActiveWorktreeId' in record) { - result.lastActiveWorktreeId = nullableString(record.lastActiveWorktreeId) - } - if ('filterRepoIds' in record) { - result.filterRepoIds = parseStringArray( - record.filterRepoIds, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES - ) - } - if ('showDotfilesByWorktree' in record) { - const entries = requiredRecord( - record.showDotfilesByWorktree, - 'orcad_migration_dormant_ui_dotfiles_invalid' - ) - if (Object.keys(entries).length > MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES) { - throw new Error('orcad_migration_dormant_ui_routing_too_many') - } - result.showDotfilesByWorktree = {} - for (const [key, enabled] of Object.entries(entries)) { - if (!key || typeof enabled !== 'boolean') { - throw new Error('orcad_migration_dormant_ui_dotfiles_invalid') - } - result.showDotfilesByWorktree[key] = enabled - } - } - if ('setupScriptPromptDismissedRepoIds' in record) { - result.setupScriptPromptDismissedRepoIds = parseStringArray( - record.setupScriptPromptDismissedRepoIds, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES - ) - } - if ('manualRepoOrder' in record) { - const entries = record.manualRepoOrder - if (!Array.isArray(entries) || entries.length > MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES) { - throw new Error('orcad_migration_dormant_ui_routing_invalid') - } - result.manualRepoOrder = entries.map((entry) => { - const parsed = requiredRecord(entry, 'orcad_migration_dormant_ui_manual_order_invalid') - const hostId = parsed.hostId - if (!isWorkspaceHostId(hostId) || typeof parsed.repoId !== 'string' || !parsed.repoId) { - throw new Error('orcad_migration_dormant_ui_manual_order_invalid') - } - const orderEntry: ManualRepoOrderEntry = { hostId, repoId: parsed.repoId } - return orderEntry - }) - } - if ('workspaceHostScope' in record) { - if (!isWorkspaceHostScope(record.workspaceHostScope)) { - throw new Error('orcad_migration_dormant_ui_host_scope_invalid') - } - result.workspaceHostScope = record.workspaceHostScope - } - if ('visibleWorkspaceHostIds' in record) { - if (record.visibleWorkspaceHostIds !== null) { - const ids = parseStringArray( - record.visibleWorkspaceHostIds, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES - ) - const hostIds: WorkspaceHostOrder = ids.filter(isWorkspaceHostId) - if (hostIds.length !== ids.length) { - throw new Error('orcad_migration_dormant_ui_visible_hosts_invalid') - } - result.visibleWorkspaceHostIds = hostIds - } else { - result.visibleWorkspaceHostIds = null - } - } - if ('workspaceHostOrder' in record) { - const ids = parseStringArray( - record.workspaceHostOrder, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES - ) - const hostIds: WorkspaceHostOrder = ids.filter(isWorkspaceHostId) - if (hostIds.length !== ids.length) { - throw new Error('orcad_migration_dormant_ui_host_order_invalid') - } - result.workspaceHostOrder = hostIds - } - if ('automationHostFilter' in record) { - const filter = requiredRecord( - record.automationHostFilter, - 'orcad_migration_dormant_ui_automation_filter_invalid' - ) - if (filter.kind === 'all') { - result.automationHostFilter = { kind: 'all' } - } else if ( - filter.kind === 'host' && - typeof filter.hostKey === 'string' && - filter.hostKey && - parseHostStableKey(filter.hostKey) - ) { - result.automationHostFilter = { kind: 'host', hostKey: filter.hostKey } - } else { - throw new Error('orcad_migration_dormant_ui_automation_filter_invalid') - } - } - if ('acknowledgedAgentsByPaneKey' in record) { - const entries = requiredRecord( - record.acknowledgedAgentsByPaneKey, - 'orcad_migration_dormant_ui_acknowledgements_invalid' - ) - if (Object.keys(entries).length > MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES) { - throw new Error('orcad_migration_dormant_ui_routing_too_many') - } - result.acknowledgedAgentsByPaneKey = {} - for (const [key, timestamp] of Object.entries(entries)) { - if (!key || typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0) { - throw new Error('orcad_migration_dormant_ui_acknowledgements_invalid') - } - result.acknowledgedAgentsByPaneKey[key] = timestamp - } - } - return result -} - -export function parseSavedPortForwards(value: unknown): SavedPortForward[] { - return parseSavedPortForwardsValue(value, MAX_ORCAD_MIGRATION_SAVED_PORT_FORWARDS) -} - -export type { ClientHostedBrowserCloseIntent } diff --git a/src/shared/orcad-migration-client-state-value-validation.ts b/src/shared/orcad-migration-client-state-value-validation.ts deleted file mode 100644 index f099f247af65..000000000000 --- a/src/shared/orcad-migration-client-state-value-validation.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { WorkspaceHostScope } from './ui-chrome-types' -import type { SavedPortForward } from './ssh-types' - -export function parseStringArray(value: unknown, max: number): string[] { - if ( - !Array.isArray(value) || - value.length > max || - !value.every((entry) => typeof entry === 'string' && entry) - ) { - throw new Error('orcad_migration_dormant_ui_routing_invalid') - } - if (new Set(value).size !== value.length) { - throw new Error('orcad_migration_dormant_ui_routing_duplicate') - } - return [...value] -} - -export function positivePort(value: unknown, label: string): number { - if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 65_535) { - throw new Error(`orcad_migration_dormant_saved_port_forward_${label}_invalid`) - } - return Number(value) -} - -export function parseSavedPortForwards(value: unknown, max: number): SavedPortForward[] { - if (!Array.isArray(value) || value.length > max) { - throw new Error('orcad_migration_dormant_saved_port_forwards_invalid') - } - const usedPorts = new Set() - return value.map((entry) => { - const record = requiredRecord(entry, 'orcad_migration_dormant_saved_port_forward_invalid') - const localPort = positivePort(record.localPort, 'local_port') - if (usedPorts.has(localPort)) { - throw new Error('orcad_migration_dormant_saved_port_forwards_duplicate') - } - usedPorts.add(localPort) - const remotePort = positivePort(record.remotePort, 'remote_port') - if (typeof record.remoteHost !== 'string' || !record.remoteHost.trim()) { - throw new Error('orcad_migration_dormant_saved_port_forward_invalid') - } - if (record.label !== undefined && typeof record.label !== 'string') { - throw new Error('orcad_migration_dormant_saved_port_forward_invalid') - } - return { - localPort, - remoteHost: record.remoteHost, - remotePort, - ...(record.label !== undefined ? { label: record.label } : {}) - } - }) -} - -export function nullableString(value: unknown): string | null { - if (value !== null && value !== undefined && (typeof value !== 'string' || value.length === 0)) { - throw new Error('orcad_migration_dormant_ui_routing_invalid') - } - return value == null ? null : value -} - -export function isWorkspaceHostScope(value: unknown): value is WorkspaceHostScope { - return value === 'all' || isWorkspaceHostId(value) -} - -export function isWorkspaceHostId(value: unknown): value is Exclude { - return ( - value === 'local' || - (typeof value === 'string' && (value.startsWith('ssh:') || value.startsWith('runtime:'))) - ) -} - -export function requiredRecord(value: unknown, error: string): Record { - if (!isRecord(value)) { - throw new Error(error) - } - return value -} - -export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/shared/orcad-migration-client-state.ts b/src/shared/orcad-migration-client-state.ts deleted file mode 100644 index 8bdde396dc73..000000000000 --- a/src/shared/orcad-migration-client-state.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { PersistedAutomationHostFilter } from './automation-host-filter' -import type { PersistedMobileClientTabSelections } from './persisted-state-types' -import type { - ManualRepoOrderEntry, - VisibleWorkspaceHostIds, - WorkspaceHostOrder, - WorkspaceHostScope -} from './ui-chrome-types' -import type { SavedPortForward } from './ssh-types' -import { - parseClientHostedBrowserCloseIntents, - parseMobileSelections, - parseSavedPortForwards, - parseUiRouting -} from './orcad-migration-client-state-parsing' -import { isWorkspaceHostId, isRecord } from './orcad-migration-client-state-value-validation' -import type { ClientHostedBrowserCloseIntent } from './client-hosted-browser-close-intent' - -export { - MAX_ORCAD_MIGRATION_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES, - MAX_ORCAD_MIGRATION_CLIENT_SELECTIONS, - MAX_ORCAD_MIGRATION_SAVED_PORT_FORWARDS, - parseClientHostedBrowserCloseIntents, - parseMobileSelections, - parseSavedPortForwards, - parseUiRouting -} from './orcad-migration-client-state-parsing' - -export type OrcadMigrationUiRoutingState = { - lastActiveRepoId?: string | null - lastActiveWorktreeId?: string | null - filterRepoIds?: string[] - showDotfilesByWorktree?: Record - setupScriptPromptDismissedRepoIds?: string[] - manualRepoOrder?: ManualRepoOrderEntry[] - workspaceHostScope?: WorkspaceHostScope - visibleWorkspaceHostIds?: VisibleWorkspaceHostIds - workspaceHostOrder?: WorkspaceHostOrder - automationHostFilter?: PersistedAutomationHostFilter - acknowledgedAgentsByPaneKey?: Record -} - -export type OrcadMigrationClientHostedBrowserCloseIntent = ClientHostedBrowserCloseIntent & { - sourceEnvironmentId: string -} - -export type OrcadMigrationClientStatePayload = { - mobileClientTabSelectionsByDeviceId?: PersistedMobileClientTabSelections - uiRouting?: OrcadMigrationUiRoutingState - savedPortForwards?: SavedPortForward[] - clientHostedBrowserCloseIntents?: OrcadMigrationClientHostedBrowserCloseIntent[] -} - -export function parseOrcadMigrationClientState(value: unknown): OrcadMigrationClientStatePayload { - if (!isRecord(value)) { - throw new Error('orcad_migration_dormant_client_state_invalid') - } - const mobile = - value.mobileClientTabSelectionsByDeviceId === undefined - ? undefined - : parseMobileSelections(value.mobileClientTabSelectionsByDeviceId) - const uiRouting = value.uiRouting === undefined ? undefined : parseUiRouting(value.uiRouting) - const savedPortForwards = - value.savedPortForwards === undefined - ? undefined - : parseSavedPortForwards(value.savedPortForwards) - const closeIntents = - value.clientHostedBrowserCloseIntents === undefined - ? undefined - : parseClientHostedBrowserCloseIntents(value.clientHostedBrowserCloseIntents) - return { - ...(mobile && Object.keys(mobile).length > 0 - ? { mobileClientTabSelectionsByDeviceId: mobile } - : {}), - ...(uiRouting && Object.keys(uiRouting).length > 0 ? { uiRouting } : {}), - ...(savedPortForwards && savedPortForwards.length > 0 ? { savedPortForwards } : {}), - ...(closeIntents && closeIntents.length > 0 - ? { clientHostedBrowserCloseIntents: closeIntents } - : {}) - } -} - -export function parseOrcadMigrationUiRoutingState(value: unknown): OrcadMigrationUiRoutingState { - return parseUiRouting(value) -} - -export function parseOrcadMigrationSavedPortForwards(value: unknown): SavedPortForward[] { - return parseSavedPortForwards(value) -} - -export function parseOrcadMigrationClientHostedBrowserCloseIntents( - value: unknown -): OrcadMigrationClientHostedBrowserCloseIntent[] { - return parseClientHostedBrowserCloseIntents(value) -} - -export function assertOrcadMigrationClientStateReferences(args: { - clientState: OrcadMigrationClientStatePayload | undefined - repositoryIds: ReadonlySet - owns: (ownerKey: string) => boolean -}): void { - const clientState = args.clientState - if (!clientState) { - return - } - for (const selections of Object.values(clientState.mobileClientTabSelectionsByDeviceId ?? {})) { - for (const worktreeId of Object.keys(selections)) { - if (!args.owns(worktreeId)) { - throw new Error('orcad_migration_dormant_mobile_selection_scope_invalid') - } - } - } - for (const intent of clientState.clientHostedBrowserCloseIntents ?? []) { - if (!intent.sourceEnvironmentId || !args.owns(intent.worktreeId)) { - throw new Error('orcad_migration_dormant_client_close_intent_scope_invalid') - } - } - const ui = clientState.uiRouting - if (!ui) { - return - } - if (ui.lastActiveRepoId && !args.repositoryIds.has(ui.lastActiveRepoId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - if (ui.lastActiveWorktreeId && !args.owns(ui.lastActiveWorktreeId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - for (const repoId of ui.filterRepoIds ?? []) { - if (!args.repositoryIds.has(repoId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - } - for (const ownerKey of Object.keys(ui.showDotfilesByWorktree ?? {})) { - if (!args.owns(ownerKey)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - } - for (const repoId of ui.setupScriptPromptDismissedRepoIds ?? []) { - if (!args.repositoryIds.has(repoId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - } - for (const entry of ui.manualRepoOrder ?? []) { - if (!args.repositoryIds.has(entry.repoId) || !isWorkspaceHostId(entry.hostId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - } -} diff --git a/src/shared/orcad-migration-dormant-automation-fields.ts b/src/shared/orcad-migration-dormant-automation-fields.ts deleted file mode 100644 index 684e7b1fd3e4..000000000000 --- a/src/shared/orcad-migration-dormant-automation-fields.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { - optionalFinite, - optionalNullableString, - requiredBoolean, - requiredFinite, - requiredRecord, - requiredString, - requiredStringOrEmpty -} from './orcad-migration-dormant-value-validation' - -// Field checks for dormant automations and their final runs; each throws its specific error. - -export function parsePrecheck(value: unknown): void { - if (value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_precheck_invalid') - requiredString(record.command, 'orcad_migration_dormant_automation_precheck_command_invalid') - requiredFinite( - record.timeoutSeconds, - 'orcad_migration_dormant_automation_precheck_timeout_invalid' - ) -} - -export function parseOutputSnapshot(value: unknown): void { - if (value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_output_invalid') - if (record.format !== 'plain_text') { - throw new Error('orcad_migration_dormant_automation_output_format_invalid') - } - requiredStringOrEmpty(record.content, 'orcad_migration_dormant_automation_output_content_invalid') - requiredFinite(record.capturedAt, 'orcad_migration_dormant_automation_output_time_invalid') - requiredBoolean(record.truncated, 'orcad_migration_dormant_automation_output_truncated_invalid') -} - -export function parsePrecheckResult(value: unknown): void { - if (value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_precheck_result_invalid') - requiredString( - record.command, - 'orcad_migration_dormant_automation_precheck_result_command_invalid' - ) - optionalFinite( - record.exitCode, - 'orcad_migration_dormant_automation_precheck_result_exit_invalid', - true - ) - requiredBoolean( - record.timedOut, - 'orcad_migration_dormant_automation_precheck_result_timeout_invalid' - ) - requiredFinite( - record.durationMs, - 'orcad_migration_dormant_automation_precheck_result_duration_invalid' - ) - for (const field of ['stdout', 'stderr'] as const) { - requiredStringOrEmpty(record[field], `orcad_migration_dormant_automation_${field}_invalid`) - requiredBoolean( - record[`${field}Truncated`], - `orcad_migration_dormant_automation_${field}_truncated_invalid` - ) - } - optionalNullableString( - record.error, - 'orcad_migration_dormant_automation_precheck_result_error_invalid' - ) - requiredFinite( - record.startedAt, - 'orcad_migration_dormant_automation_precheck_result_started_invalid' - ) - requiredFinite( - record.completedAt, - 'orcad_migration_dormant_automation_precheck_result_completed_invalid' - ) -} - -export function parseUsage(value: unknown): void { - if (value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_usage_invalid') - if (record.status !== 'known' && record.status !== 'unavailable') { - throw new Error('orcad_migration_dormant_automation_usage_status_invalid') - } - for (const field of [ - 'inputTokens', - 'outputTokens', - 'cacheReadTokens', - 'cacheWriteTokens', - 'reasoningOutputTokens', - 'totalTokens', - 'estimatedCostUsd' - ] as const) { - optionalFinite(record[field], `orcad_migration_dormant_automation_usage_${field}_invalid`, true) - } - requiredFinite(record.collectedAt, 'orcad_migration_dormant_automation_usage_collected_invalid') -} diff --git a/src/shared/orcad-migration-dormant-automation-validation.ts b/src/shared/orcad-migration-dormant-automation-validation.ts deleted file mode 100644 index 61787127ba92..000000000000 --- a/src/shared/orcad-migration-dormant-automation-validation.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { isTuiAgent } from './tui-agent-config' -import type { Automation, AutomationRun } from './automations-types' -import { getAutomationRunRepoId } from './automation-run-identity' -import { - assertUnique, - boundedArray, - optionalFinite, - optionalNullableString, - optionalPositiveInteger, - requiredBoolean, - requiredFinite, - requiredRecord, - requiredString, - requiredStringOrEmpty -} from './orcad-migration-dormant-value-validation' -import { - parseOutputSnapshot, - parsePrecheck, - parsePrecheckResult, - parseUsage -} from './orcad-migration-dormant-automation-fields' - -const FINAL_RUN_STATUSES = new Set([ - 'completed', - 'dispatch_failed', - 'skipped_precheck', - 'skipped_missed', - 'skipped_unavailable', - 'skipped_needs_interactive_auth' -]) - -export function parseOrcadMigrationDormantAutomations(value: unknown): Automation[] { - const automations = boundedArray(value, parseAutomation, 'automations') - assertUnique(automations, (entry) => entry.id, 'automation') - return automations -} - -export function parseOrcadMigrationDormantAutomationRuns(value: unknown): AutomationRun[] { - const runs = boundedArray(value, parseAutomationRun, 'automation_runs') - assertUnique(runs, (entry) => entry.id, 'automation_run') - return runs -} - -export function assertOrcadMigrationDormantAutomationReferences(args: { - automations: readonly Automation[] - automationRuns: readonly AutomationRun[] - repositoryIds: ReadonlySet - owns: (value: string) => boolean -}): void { - const automationIds = new Set(args.automations.map((entry) => entry.id)) - for (const automation of args.automations) { - const repoId = getAutomationRunRepoId(automation) - if ( - !args.repositoryIds.has(repoId) || - !contextBelongsToDestination(automation.runContext, args.repositoryIds) || - !contextBelongsToDestination(automation.sourceContext, args.repositoryIds) || - (automation.workspaceId !== null && !args.owns(automation.workspaceId)) - ) { - throw new Error('orcad_migration_dormant_automation_scope_invalid') - } - } - for (const run of args.automationRuns) { - if ( - !automationIds.has(run.automationId) || - !contextBelongsToDestination(run.runContext, args.repositoryIds) || - !contextBelongsToDestination(run.sourceContext, args.repositoryIds) || - (run.workspaceId !== null && !args.owns(run.workspaceId)) - ) { - throw new Error('orcad_migration_dormant_automation_run_scope_invalid') - } - } -} - -function parseAutomation(value: unknown): Automation { - const copy = structuredClone(value) - if (!isMigratedAutomation(copy)) { - throw new Error('orcad_migration_dormant_automation_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedAutomation(value: unknown): value is Automation { - const record = requiredRecord(value, 'orcad_migration_dormant_automation_invalid') - requiredString(record.id, 'orcad_migration_dormant_automation_id_invalid') - requiredString(record.name, 'orcad_migration_dormant_automation_name_invalid') - requiredStringOrEmpty(record.prompt, 'orcad_migration_dormant_automation_prompt_invalid') - parsePrecheck(record.precheck) - if (!isTuiAgent(record.agentId)) { - throw new Error('orcad_migration_dormant_automation_agent_invalid') - } - parseContext(record.runContext, 'workspace-run') - parseContext(record.sourceContext, 'task-source') - requiredString(record.projectId, 'orcad_migration_dormant_automation_project_invalid') - if ( - record.executionTargetType !== 'local' || - record.executionTargetId !== 'local' || - record.executionTargetGeneration !== undefined || - record.schedulerOwner !== 'remote_host_service' || - record.enabled !== false - ) { - throw new Error('orcad_migration_dormant_automation_owner_invalid') - } - if (!['existing', 'new_per_run'].includes(String(record.workspaceMode))) { - throw new Error('orcad_migration_dormant_automation_workspace_mode_invalid') - } - optionalNullableString(record.workspaceId, 'orcad_migration_dormant_automation_workspace_invalid') - optionalNullableString(record.baseBranch, 'orcad_migration_dormant_automation_branch_invalid') - if ( - record.setupDecision !== undefined && - record.setupDecision !== 'run' && - record.setupDecision !== 'skip' - ) { - throw new Error('orcad_migration_dormant_automation_setup_decision_invalid') - } - requiredBoolean(record.reuseSession, 'orcad_migration_dormant_automation_reuse_invalid') - requiredString(record.timezone, 'orcad_migration_dormant_automation_timezone_invalid') - requiredString(record.rrule, 'orcad_migration_dormant_automation_rrule_invalid') - for (const field of [ - 'dtstart', - 'nextRunAt', - 'missedRunGraceMinutes', - 'createdAt', - 'updatedAt' - ] as const) { - requiredFinite(record[field], `orcad_migration_dormant_automation_${field}_invalid`) - } - optionalFinite(record.lastRunAt, 'orcad_migration_dormant_automation_last_run_invalid') - if (record.missedRunPolicy !== 'run_once_within_grace') { - throw new Error('orcad_migration_dormant_automation_missed_policy_invalid') - } - return true -} - -function parseAutomationRun(value: unknown): AutomationRun { - const copy = structuredClone(value) - if (!isMigratedAutomationRun(copy)) { - throw new Error('orcad_migration_dormant_automation_run_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedAutomationRun(value: unknown): value is AutomationRun { - const record = requiredRecord(value, 'orcad_migration_dormant_automation_run_invalid') - requiredString(record.id, 'orcad_migration_dormant_automation_run_id_invalid') - requiredString(record.automationId, 'orcad_migration_dormant_automation_run_owner_invalid') - parseContext(record.runContext, 'workspace-run') - parseContext(record.sourceContext, 'task-source') - requiredString(record.title, 'orcad_migration_dormant_automation_run_title_invalid') - requiredFinite(record.scheduledFor, 'orcad_migration_dormant_automation_run_schedule_invalid') - if (!FINAL_RUN_STATUSES.has(record.status)) { - throw new Error('orcad_migration_dormant_automation_run_status_invalid') - } - if (record.trigger !== 'scheduled' && record.trigger !== 'manual') { - throw new Error('orcad_migration_dormant_automation_run_trigger_invalid') - } - optionalNullableString( - record.workspaceId, - 'orcad_migration_dormant_automation_run_workspace_invalid' - ) - optionalNullableString( - record.workspaceDisplayName, - 'orcad_migration_dormant_automation_run_workspace_name_invalid' - ) - if (record.sessionKind !== 'terminal') { - throw new Error('orcad_migration_dormant_automation_run_session_invalid') - } - for (const field of [ - 'chatSessionId', - 'terminalSessionId', - 'terminalPaneKey', - 'terminalPtyId', - 'error' - ] as const) { - optionalNullableString(record[field], `orcad_migration_dormant_automation_run_${field}_invalid`) - } - parseOutputSnapshot(record.outputSnapshot) - parsePrecheckResult(record.precheckResult) - parseUsage(record.usage) - optionalFinite(record.startedAt, 'orcad_migration_dormant_automation_run_started_invalid', true) - optionalFinite( - record.dispatchedAt, - 'orcad_migration_dormant_automation_run_dispatched_invalid', - true - ) - requiredFinite(record.createdAt, 'orcad_migration_dormant_automation_run_created_invalid') - optionalPositiveInteger(record.runNumber, 'orcad_migration_dormant_automation_run_number_invalid') - optionalPositiveInteger( - record.occurrenceCount, - 'orcad_migration_dormant_automation_occurrence_count_invalid' - ) - optionalFinite( - record.lastOccurrenceAt, - 'orcad_migration_dormant_automation_last_occurrence_invalid' - ) - return true -} - -function parseContext(value: unknown, kind: 'workspace-run' | 'task-source'): void { - if (value === undefined || value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_context_invalid') - if (record.kind !== kind || record.hostId !== 'local') { - throw new Error('orcad_migration_dormant_automation_context_owner_invalid') - } - requiredString(record.projectId, 'orcad_migration_dormant_automation_context_project_invalid') - optionalNullableString( - record.projectHostSetupId, - 'orcad_migration_dormant_automation_context_setup_invalid' - ) - optionalNullableString(record.repoId, 'orcad_migration_dormant_automation_context_repo_invalid') - if (kind === 'workspace-run') { - requiredString( - record.projectHostSetupId, - 'orcad_migration_dormant_automation_context_setup_invalid' - ) - requiredString(record.repoId, 'orcad_migration_dormant_automation_context_repo_invalid') - requiredString(record.path, 'orcad_migration_dormant_automation_context_path_invalid') - } else if (!['github', 'gitlab', 'linear', 'jira'].includes(String(record.provider))) { - throw new Error('orcad_migration_dormant_automation_context_provider_invalid') - } -} - -function contextBelongsToDestination( - context: Automation['runContext'] | Automation['sourceContext'], - repositoryIds: ReadonlySet -): boolean { - if (!context) { - return true - } - if (context.hostId !== 'local') { - return false - } - if (context.repoId && !repositoryIds.has(context.repoId)) { - return false - } - return !context.projectHostSetupId || context.projectHostSetupId === context.repoId -} diff --git a/src/shared/orcad-migration-dormant-session-validation.test.ts b/src/shared/orcad-migration-dormant-session-validation.test.ts deleted file mode 100644 index 0c3e31937b4a..000000000000 --- a/src/shared/orcad-migration-dormant-session-validation.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from './constants' -import { - CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, - type PersistedClientHostedBrowserPage -} from './client-hosted-browser-page-record' -import type { BrowserWorkspace } from './browser-workspace-types' -import { assertOrcadMigrationDormantWorkspaceSessionReferences } from './orcad-migration-dormant-session-validation' -import { parseOrcadMigrationClientState } from './orcad-migration-client-state' -import type { WorkspaceSessionState } from './workspace-session-state-types' - -const OWNER = 'repo-1::/srv/worktree' -const BROWSER_WORKSPACE_ID = 'browser-workspace-1' - -function browserWorkspace(): BrowserWorkspace { - return { - id: BROWSER_WORKSPACE_ID, - worktreeId: OWNER, - activePageId: null, - pageIds: [], - url: 'about:blank', - title: 'Browser', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } -} - -function clientPage( - browserPageId: string, - workspaceId = BROWSER_WORKSPACE_ID -): PersistedClientHostedBrowserPage { - return { - v: CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, - browserPageId, - workspaceId, - browserProfileId: 'profile-1', - url: 'https://example.test/', - title: 'Example', - pairedDeviceId: 'device-1', - savedAt: 1 - } -} - -function validate(session: WorkspaceSessionState): void { - assertOrcadMigrationDormantWorkspaceSessionReferences({ - session, - owns: (ownerKey) => ownerKey === OWNER, - repositoryIds: new Set(['repo-1']) - }) -} - -function sessionWithPages(pages: ReturnType[]): WorkspaceSessionState { - return { - ...getDefaultWorkspaceSession(), - browserTabsByWorktree: { [OWNER]: [browserWorkspace()] }, - clientHostedBrowserPagesByWorktree: { [OWNER]: pages } - } -} - -describe('dormant client-hosted browser page migration validation', () => { - it('accepts held pages whose workspace id belongs to the transferred browser workspace', () => { - expect(() => validate(sessionWithPages([clientPage('page-1')]))).not.toThrow() - }) - - it('rejects a page that names a browser workspace outside its owner', () => { - expect(() => validate(sessionWithPages([clientPage('page-1', 'browser-missing')]))).toThrow( - 'orcad_migration_dormant_workspace_session_client_page_scope_invalid' - ) - }) - - it('rejects duplicate client-hosted page identities', () => { - expect(() => validate(sessionWithPages([clientPage('page-1'), clientPage('page-1')]))).toThrow( - 'orcad_migration_dormant_workspace_session_identity_duplicate' - ) - }) - - it('accepts scoped client-owned close intents alongside transferred pages', () => { - const session = sessionWithPages([clientPage('page-1')]) - session.clientHostedBrowserCloseIntentsByEnvironment = { - 'old-environment': [{ browserPageId: 'page-1', worktreeId: OWNER, closedAt: 10 }] - } - expect(() => validate(session)).not.toThrow() - }) - - it('parses migration close-intent envelopes with source environment identity', () => { - expect( - parseOrcadMigrationClientState({ - clientHostedBrowserCloseIntents: [ - { - sourceEnvironmentId: 'old-environment', - browserPageId: 'page-1', - worktreeId: OWNER, - closedAt: 10 - } - ] - }) - ).toEqual({ - clientHostedBrowserCloseIntents: [ - { - sourceEnvironmentId: 'old-environment', - browserPageId: 'page-1', - worktreeId: OWNER, - closedAt: 10 - } - ] - }) - }) - - it('allows global browser URL history to remain client-owned', () => { - const session = sessionWithPages([]) - session.browserUrlHistory = [ - { - url: 'https://example.test/', - normalizedUrl: 'https://example.test/', - title: 'Example', - lastVisitedAt: 10, - visitCount: 1 - } - ] - expect(() => validate(session)).not.toThrow() - }) - - it('accepts markdown visibility only when it names a persisted open file', () => { - const session = sessionWithPages([]) - session.openFilesByWorktree = { - [OWNER]: [ - { - filePath: '/srv/worktree/README.md', - relativePath: 'README.md', - worktreeId: OWNER, - language: 'markdown' - } - ] - } - session.markdownFrontmatterVisible = { '/srv/worktree/README.md': false } - expect(() => validate(session)).not.toThrow() - session.markdownFrontmatterVisible = { '/srv/worktree/missing.md': false } - expect(() => validate(session)).toThrow( - 'orcad_migration_dormant_workspace_session_markdown_scope_invalid' - ) - }) - - it('accepts a terminal layout referenced only by the unified tab model', () => { - const session: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: {}, - unifiedTabs: { - [OWNER]: [ - { - id: 'terminal-tab-1', - entityId: 'terminal-tab-1', - groupId: 'group-1', - worktreeId: OWNER, - contentType: 'terminal' as const, - label: 'Terminal', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'terminal-tab-1': { - root: { type: 'leaf' as const, leafId: 'leaf-1' }, - activeLeafId: 'leaf-1', - expandedLeafId: null - } - } - } - expect(() => validate(session)).not.toThrow() - }) -}) diff --git a/src/shared/orcad-migration-dormant-session-validation.ts b/src/shared/orcad-migration-dormant-session-validation.ts deleted file mode 100644 index 138e9b41fabb..000000000000 --- a/src/shared/orcad-migration-dormant-session-validation.ts +++ /dev/null @@ -1,271 +0,0 @@ -import type { WorkspaceSessionState } from './workspace-session-state-types' -import { parseWorkspaceSession } from './workspace-session-schema' - -export function parseOrcadMigrationDormantWorkspaceSession(value: unknown): WorkspaceSessionState { - const parsed = parseWorkspaceSession(value) - if (!parsed.ok) { - throw new Error(`orcad_migration_dormant_workspace_session_invalid:${parsed.error}`) - } - return parsed.value -} - -export function assertOrcadMigrationDormantWorkspaceSessionReferences(args: { - session: WorkspaceSessionState - owns: (ownerKey: string) => boolean - repositoryIds: ReadonlySet -}): void { - const { session } = args - const terminalTabIds = new Set() - const browserWorkspaceIds = new Set() - const browserWorkspaceOwnerById = new Map() - const clientHostedBrowserPageIds = new Set() - const clientHostedBrowserPageOwnerById = new Map() - const unifiedTabIds = new Set() - const tabGroupIds = new Set() - for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { - assertOwner(args.owns, ownerKey) - for (const tab of tabs) { - assertOwner(args.owns, tab.worktreeId) - if (tab.ptyId) { - throw new Error('orcad_migration_dormant_workspace_session_pty_invalid') - } - addUniqueSessionId(terminalTabIds, tab.id) - } - } - for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { - assertOwner(args.owns, ownerKey) - for (const file of files) { - assertOwner(args.owns, file.worktreeId) - if (file.externalSshTargetId) { - throw new Error('orcad_migration_dormant_workspace_session_external_file_invalid') - } - if (file.runtimeEnvironmentId) { - throw new Error('orcad_migration_dormant_workspace_session_file_runtime_invalid') - } - } - } - const markdownFileIdCounts = new Map() - for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { - if (!args.owns(ownerKey)) { - continue - } - for (const file of files) { - for (const fileId of [ - file.filePath, - ownedEditorFileId(file.filePath, ownerKey, file.runtimeEnvironmentId) - ]) { - markdownFileIdCounts.set(fileId, (markdownFileIdCounts.get(fileId) ?? 0) + 1) - } - } - } - for (const fileId of Object.keys(session.markdownFrontmatterVisible ?? {})) { - if (markdownFileIdCounts.get(fileId) !== 1) { - throw new Error('orcad_migration_dormant_workspace_session_markdown_scope_invalid') - } - } - for (const [ownerKey, workspaces] of Object.entries(session.browserTabsByWorktree ?? {})) { - assertOwner(args.owns, ownerKey) - for (const workspace of workspaces) { - assertOwner(args.owns, workspace.worktreeId) - if (workspace.sessionProfileId || workspace.sessionPartition) { - throw new Error('orcad_migration_dormant_workspace_session_browser_profile_invalid') - } - addUniqueSessionId(browserWorkspaceIds, workspace.id) - browserWorkspaceOwnerById.set(workspace.id, ownerKey) - } - } - for (const [workspaceId, pages] of Object.entries(session.browserPagesByWorkspace ?? {})) { - if (!browserWorkspaceIds.has(workspaceId)) { - throw new Error('orcad_migration_dormant_workspace_session_browser_page_scope_invalid') - } - pages.forEach((page) => assertOwner(args.owns, page.worktreeId)) - } - for (const [ownerKey, pages] of Object.entries( - session.clientHostedBrowserPagesByWorktree ?? {} - )) { - assertOwner(args.owns, ownerKey) - pages.forEach((page) => { - if ( - !browserWorkspaceIds.has(page.workspaceId) || - browserWorkspaceOwnerById.get(page.workspaceId) !== ownerKey - ) { - throw new Error('orcad_migration_dormant_workspace_session_client_page_scope_invalid') - } - addUniqueSessionId(clientHostedBrowserPageIds, page.browserPageId) - clientHostedBrowserPageOwnerById.set(page.browserPageId, ownerKey) - }) - } - const closeIntentKeys = new Set() - for (const [environmentId, intents] of Object.entries( - session.clientHostedBrowserCloseIntentsByEnvironment ?? {} - )) { - if (!environmentId) { - throw new Error('orcad_migration_dormant_workspace_session_close_intent_scope_invalid') - } - for (const intent of intents) { - assertOwner(args.owns, intent.worktreeId) - const ownerKey = clientHostedBrowserPageOwnerById.get(intent.browserPageId) - if (!clientHostedBrowserPageIds.has(intent.browserPageId) || ownerKey !== intent.worktreeId) { - throw new Error('orcad_migration_dormant_workspace_session_close_intent_scope_invalid') - } - const key = `${environmentId}\0${intent.browserPageId}\0${intent.worktreeId}` - if (closeIntentKeys.has(key)) { - throw new Error('orcad_migration_dormant_workspace_session_identity_duplicate') - } - closeIntentKeys.add(key) - } - } - for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { - assertOwner(args.owns, ownerKey) - for (const tab of tabs) { - assertOwner(args.owns, tab.worktreeId) - if (tab.contentType === 'terminal') { - // Unified terminal tabs normally mirror tabsByWorktree, but a session written during - // model rollout can contain only this representation. Layouts still belong to it. - terminalTabIds.add(tab.id) - terminalTabIds.add(tab.entityId) - } - addUniqueSessionId(unifiedTabIds, tab.id) - if (tab.executionHostId !== undefined && tab.executionHostId !== 'local') { - throw new Error('orcad_migration_dormant_workspace_session_host_invalid') - } - } - } - for (const [ownerKey, groups] of Object.entries(session.tabGroups ?? {})) { - assertOwner(args.owns, ownerKey) - groups.forEach((group) => { - assertOwner(args.owns, group.worktreeId) - addUniqueSessionId(tabGroupIds, group.id) - }) - } - assertOwnerRecordKeys(args.owns, [ - session.activeFileIdByWorktree, - session.activeBrowserTabIdByWorktree, - session.activeTabTypeByWorktree, - session.activeTabIdByWorktree, - session.tabGroupLayouts, - session.activeGroupIdByWorktree, - session.lastVisitedAtByWorktreeId, - session.defaultTerminalTabsAppliedByWorktreeId - ]) - for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId)) { - if ( - !terminalTabIds.has(tabId) || - Object.keys(layout.ptyIdsByLeafId ?? {}).length > 0 || - Object.keys(layout.buffersByLeafId ?? {}).length > 0 - ) { - throw new Error('orcad_migration_dormant_workspace_session_layout_invalid') - } - } - if (session.activeRepoId !== null && !args.repositoryIds.has(session.activeRepoId)) { - throw new Error('orcad_migration_dormant_workspace_session_focus_scope_invalid') - } - if (session.activeWorktreeId !== null) { - assertOwner(args.owns, session.activeWorktreeId) - } - if (session.activeWorkspaceKey != null) { - assertOwner(args.owns, session.activeWorkspaceKey) - } - if ( - session.activeWorkspaceExecutionHostId !== undefined && - session.activeWorkspaceExecutionHostId !== null && - session.activeWorkspaceExecutionHostId !== 'local' - ) { - throw new Error('orcad_migration_dormant_workspace_session_host_invalid') - } - if (session.activeTabId !== null && !terminalTabIds.has(session.activeTabId)) { - throw new Error('orcad_migration_dormant_workspace_session_focus_scope_invalid') - } - for (const entry of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { - assertOwner(args.owns, entry.worktreeId) - } - for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { - assertOwner(args.owns, record.worktreeId) - if ( - record.connectionId != null || - ('automaticResumeBlockedBy' in record && record.automaticResumeBlockedBy !== undefined) - ) { - throw new Error('orcad_migration_dormant_workspace_session_agent_authority_invalid') - } - if ( - (record.origin === 'quit' || record.origin === 'live') && - !paneBelongsToTerminalLayout(record, session, terminalTabIds) - ) { - throw new Error('orcad_migration_dormant_workspace_session_agent_pane_invalid') - } - } - for (const repoId of Object.keys(session.terminalTopologyRevisionByRepoId ?? {})) { - if (!args.repositoryIds.has(repoId)) { - throw new Error('orcad_migration_dormant_workspace_session_topology_scope_invalid') - } - } - if ( - (session.activeWorktreeIdsOnShutdown?.length ?? 0) > 0 || - (session.activeConnectionIdsAtShutdown?.length ?? 0) > 0 || - Object.keys(session.remoteSessionIdsByTabId ?? {}).length > 0 || - Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).length > 0 - ) { - throw new Error('orcad_migration_dormant_workspace_session_live_or_client_state_invalid') - } -} - -function ownedEditorFileId( - filePath: string, - worktreeId: string, - runtimeEnvironmentId: string | null | undefined -): string { - const runtimeKey = runtimeEnvironmentId?.trim() || 'local' - return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}` -} - -function paneBelongsToTerminalLayout( - record: NonNullable[string], - session: WorkspaceSessionState, - tabIds: ReadonlySet -): boolean { - const separator = record.paneKey.lastIndexOf(':') - if (separator < 1) { - return false - } - const tabId = record.paneKey.slice(0, separator) - const leafId = record.paneKey.slice(separator + 1) - if ((record.tabId !== undefined && record.tabId !== tabId) || !tabIds.has(tabId)) { - return false - } - return terminalLayoutContainsLeaf(session.terminalLayoutsByTabId[tabId]?.root, leafId) -} - -function terminalLayoutContainsLeaf( - node: WorkspaceSessionState['terminalLayoutsByTabId'][string]['root'] | undefined, - leafId: string -): boolean { - return Boolean( - node && - (node.type === 'leaf' - ? node.leafId === leafId - : terminalLayoutContainsLeaf(node.first, leafId) || - terminalLayoutContainsLeaf(node.second, leafId)) - ) -} - -function assertOwnerRecordKeys( - owns: (ownerKey: string) => boolean, - records: (Record | undefined)[] -): void { - for (const record of records) { - Object.keys(record ?? {}).forEach((ownerKey) => assertOwner(owns, ownerKey)) - } -} - -function assertOwner(owns: (ownerKey: string) => boolean, ownerKey: string): void { - if (!owns(ownerKey)) { - throw new Error('orcad_migration_dormant_workspace_session_scope_invalid') - } -} - -function addUniqueSessionId(ids: Set, id: string): void { - if (ids.has(id)) { - throw new Error('orcad_migration_dormant_workspace_session_identity_duplicate') - } - ids.add(id) -} diff --git a/src/shared/orcad-migration-dormant-state-entry-validation.ts b/src/shared/orcad-migration-dormant-state-entry-validation.ts deleted file mode 100644 index 5341b91b5784..000000000000 --- a/src/shared/orcad-migration-dormant-state-entry-validation.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { - OrcadMigrationDormantRetiredNames, - OrcadMigrationDormantRetirementNamespace, - OrcadMigrationDormantWorktreeLineage, - OrcadMigrationDormantWorkspaceLineage, - OrcadMigrationDormantWorktreeMeta -} from './orcad-migration-manifest' -import type { SparsePreset } from './worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from './worktree/lineage-types' -import type { WorktreeMeta } from './worktree/meta-types' -import type { RetiredNameRegistry } from './worktree/retired-name-registry' -import { parseWorkspaceKey } from './workspace-scope' -import { - isString, - optionalNullableString, - requiredBoolean, - requiredFinite, - requiredRecord, - requiredString, - requiredStringOrEmpty, - stringArray -} from './orcad-migration-dormant-value-validation' - -const MAX_RETIRED_NAMES = 2_048 - -export function parseWorktreeMetaEntry(value: unknown): OrcadMigrationDormantWorktreeMeta { - const record = requiredRecord(value, 'orcad_migration_dormant_worktree_meta_invalid') - const meta = structuredClone(record.meta) - if (!isMigratedWorktreeMeta(meta)) { - throw new Error('orcad_migration_dormant_worktree_meta_value_invalid') - } - return { - sourceKey: requiredString(record.sourceKey, 'orcad_migration_dormant_source_key_invalid'), - worktreeId: requiredString(record.worktreeId, 'orcad_migration_dormant_worktree_id_invalid'), - meta - } -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedWorktreeMeta(value: unknown): value is WorktreeMeta { - const meta = requiredRecord(value, 'orcad_migration_dormant_worktree_meta_value_invalid') - requiredStringOrEmpty( - meta.displayName, - 'orcad_migration_dormant_worktree_meta_display_name_invalid' - ) - requiredStringOrEmpty(meta.comment, 'orcad_migration_dormant_worktree_meta_comment_invalid') - requiredBoolean(meta.isArchived, 'orcad_migration_dormant_worktree_meta_archived_invalid') - requiredBoolean(meta.isUnread, 'orcad_migration_dormant_worktree_meta_unread_invalid') - requiredBoolean(meta.isPinned, 'orcad_migration_dormant_worktree_meta_pinned_invalid') - requiredFinite(meta.sortOrder, 'orcad_migration_dormant_worktree_meta_sort_order_invalid') - requiredFinite(meta.lastActivityAt, 'orcad_migration_dormant_worktree_meta_activity_invalid') - return true -} - -export function parseWorktreeLineageEntry(value: unknown): OrcadMigrationDormantWorktreeLineage { - const record = requiredRecord(value, 'orcad_migration_dormant_worktree_lineage_invalid') - const lineage = parseWorktreeLineage(record.lineage) - return { - sourceKey: requiredString(record.sourceKey, 'orcad_migration_dormant_source_key_invalid'), - worktreeId: requiredString(record.worktreeId, 'orcad_migration_dormant_worktree_id_invalid'), - lineage - } -} - -export function parseWorkspaceLineageEntry(value: unknown): OrcadMigrationDormantWorkspaceLineage { - const record = requiredRecord(value, 'orcad_migration_dormant_workspace_lineage_invalid') - const lineage = parseWorkspaceLineage(record.lineage) - return { - sourceKey: requiredString(record.sourceKey, 'orcad_migration_dormant_source_key_invalid'), - childWorkspaceKey: requiredString( - record.childWorkspaceKey, - 'orcad_migration_dormant_workspace_key_invalid' - ), - lineage - } -} - -function parseWorktreeLineage(value: unknown): WorktreeLineage { - const copy = structuredClone(value) - if (!isMigratedWorktreeLineage(copy)) { - throw new Error('orcad_migration_dormant_worktree_lineage_value_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedWorktreeLineage(value: unknown): value is WorktreeLineage { - const record = requiredRecord(value, 'orcad_migration_dormant_worktree_lineage_value_invalid') - requiredString(record.worktreeId, 'orcad_migration_dormant_lineage_worktree_invalid') - requiredString(record.worktreeInstanceId, 'orcad_migration_dormant_lineage_instance_invalid') - requiredString(record.parentWorktreeId, 'orcad_migration_dormant_lineage_parent_invalid') - requiredString( - record.parentWorktreeInstanceId, - 'orcad_migration_dormant_lineage_parent_instance_invalid' - ) - parseLineageBase(record) - return true -} - -function parseWorkspaceLineage(value: unknown): WorkspaceLineage { - const lineage = structuredClone(value) - if (!isMigratedWorkspaceLineage(lineage)) { - throw new Error('orcad_migration_dormant_workspace_lineage_value_invalid') - } - return { - ...lineage, - childInstanceId: lineage.childInstanceId ?? null, - parentInstanceId: lineage.parentInstanceId ?? null - } -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedWorkspaceLineage(value: unknown): value is WorkspaceLineage { - const record = requiredRecord(value, 'orcad_migration_dormant_workspace_lineage_value_invalid') - const child = requiredString( - record.childWorkspaceKey, - 'orcad_migration_dormant_lineage_child_invalid' - ) - const parent = requiredString( - record.parentWorkspaceKey, - 'orcad_migration_dormant_lineage_parent_invalid' - ) - if (!parseWorkspaceKey(child) || !parseWorkspaceKey(parent)) { - throw new Error('orcad_migration_dormant_lineage_workspace_key_invalid') - } - parseLineageBase(record) - optionalNullableString( - record.childInstanceId, - 'orcad_migration_dormant_lineage_child_instance_invalid' - ) - optionalNullableString( - record.parentInstanceId, - 'orcad_migration_dormant_lineage_parent_instance_invalid' - ) - return true -} - -function parseLineageBase(record: Record): void { - if (!['orchestration', 'cli', 'manual'].includes(String(record.origin))) { - throw new Error('orcad_migration_dormant_lineage_origin_invalid') - } - const capture = requiredRecord(record.capture, 'orcad_migration_dormant_lineage_capture_invalid') - if (!['explicit', 'inferred'].includes(String(capture.confidence))) { - throw new Error('orcad_migration_dormant_lineage_confidence_invalid') - } - requiredString(capture.source, 'orcad_migration_dormant_lineage_source_invalid') - requiredFinite(record.createdAt, 'orcad_migration_dormant_lineage_created_at_invalid') -} - -export function parseSparsePreset(value: unknown): SparsePreset { - const copy = structuredClone(value) - if (!isMigratedSparsePreset(copy)) { - throw new Error('orcad_migration_dormant_sparse_preset_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedSparsePreset(value: unknown): value is SparsePreset { - const record = requiredRecord(value, 'orcad_migration_dormant_sparse_preset_invalid') - requiredString(record.id, 'orcad_migration_dormant_sparse_preset_id_invalid') - requiredString(record.repoId, 'orcad_migration_dormant_sparse_preset_repo_invalid') - requiredString(record.name, 'orcad_migration_dormant_sparse_preset_name_invalid') - if (!Array.isArray(record.directories) || !record.directories.every(isString)) { - throw new Error('orcad_migration_dormant_sparse_preset_directories_invalid') - } - requiredFinite(record.createdAt, 'orcad_migration_dormant_sparse_preset_created_at_invalid') - requiredFinite(record.updatedAt, 'orcad_migration_dormant_sparse_preset_updated_at_invalid') - return true -} - -export function parseRetiredNames(value: unknown): OrcadMigrationDormantRetiredNames { - const record = requiredRecord(value, 'orcad_migration_dormant_retired_names_invalid') - return { - repoId: requiredString(record.repoId, 'orcad_migration_dormant_retired_names_repo_invalid'), - registry: parseRetiredNameRegistry(record.registry) - } -} - -export function parseRetirementNamespace(value: unknown): OrcadMigrationDormantRetirementNamespace { - const record = requiredRecord(value, 'orcad_migration_dormant_retirement_namespace_invalid') - const sourceNamespaceKeys = stringArray(record.sourceNamespaceKeys) - if (sourceNamespaceKeys.length === 0) { - throw new Error('orcad_migration_dormant_retirement_namespace_sources_invalid') - } - return { - sourceNamespaceKeys, - namespaceKey: requiredString( - record.namespaceKey, - 'orcad_migration_dormant_retirement_namespace_key_invalid' - ), - registry: parseRetiredNameRegistry(record.registry) - } -} - -function parseRetiredNameRegistry(value: unknown): RetiredNameRegistry { - const record = requiredRecord(value, 'orcad_migration_dormant_retired_registry_invalid') - if (!Number.isInteger(record.exhaustedTiers) || Number(record.exhaustedTiers) < 0) { - throw new Error('orcad_migration_dormant_retired_registry_watermark_invalid') - } - const names = stringArray(record.names, MAX_RETIRED_NAMES) - return { exhaustedTiers: Number(record.exhaustedTiers), names } -} diff --git a/src/shared/orcad-migration-dormant-state-validation.ts b/src/shared/orcad-migration-dormant-state-validation.ts deleted file mode 100644 index 87793ef11336..000000000000 --- a/src/shared/orcad-migration-dormant-state-validation.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { OrcadMigrationDormantStatePayload } from './orcad-migration-manifest' -import { getRepoIdFromWorktreeId } from './worktree/id' -import { parseWorkspaceKey } from './workspace-scope' -import { - assertOrcadMigrationDormantWorkspaceSessionReferences, - parseOrcadMigrationDormantWorkspaceSession -} from './orcad-migration-dormant-session-validation' -import { - assertOrcadMigrationDormantAutomationReferences, - parseOrcadMigrationDormantAutomationRuns, - parseOrcadMigrationDormantAutomations -} from './orcad-migration-dormant-automation-validation' -import { - assertOrcadMigrationScrollbackReferences, - parseOrcadMigrationTerminalScrollbackSnapshots -} from './orcad-migration-scrollback' -import { - assertOrcadMigrationClientStateReferences, - parseOrcadMigrationClientState -} from './orcad-migration-client-state' -import { - assertUnique, - boundedArray, - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES, - requiredRecord -} from './orcad-migration-dormant-value-validation' -import { - parseRetiredNames, - parseRetirementNamespace, - parseSparsePreset, - parseWorkspaceLineageEntry, - parseWorktreeLineageEntry, - parseWorktreeMetaEntry -} from './orcad-migration-dormant-state-entry-validation' - -export { - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES, - MAX_ORCAD_MIGRATION_DORMANT_ROWS -} from './orcad-migration-dormant-value-validation' - -export const ORCAD_MIGRATION_DORMANT_STATE_VERSION = 1 as const -export function parseOrcadMigrationDormantState(value: unknown): OrcadMigrationDormantStatePayload { - const record = requiredRecord(value, 'orcad_migration_dormant_state_invalid') - if (record.version !== ORCAD_MIGRATION_DORMANT_STATE_VERSION) { - throw new Error('orcad_migration_dormant_state_version_unsupported') - } - const payload: OrcadMigrationDormantStatePayload = { - version: ORCAD_MIGRATION_DORMANT_STATE_VERSION, - worktreeMeta: boundedArray(record.worktreeMeta, parseWorktreeMetaEntry, 'worktree_meta'), - worktreeLineage: boundedArray( - record.worktreeLineage, - parseWorktreeLineageEntry, - 'worktree_lineage' - ), - workspaceLineage: boundedArray( - record.workspaceLineage, - parseWorkspaceLineageEntry, - 'workspace_lineage' - ), - sparsePresets: boundedArray(record.sparsePresets, parseSparsePreset, 'sparse_presets'), - retiredWorktreeNames: boundedArray( - record.retiredWorktreeNames, - parseRetiredNames, - 'retired_names' - ), - retiredWorktreeNamespaces: boundedArray( - record.retiredWorktreeNamespaces, - parseRetirementNamespace, - 'retirement_namespaces', - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES - ), - ...(record.workspaceSession === undefined - ? {} - : { workspaceSession: parseOrcadMigrationDormantWorkspaceSession(record.workspaceSession) }), - ...(record.terminalScrollbackSnapshots === undefined - ? {} - : { - terminalScrollbackSnapshots: parseOrcadMigrationTerminalScrollbackSnapshots( - record.terminalScrollbackSnapshots - ) - }), - ...(record.automations === undefined - ? {} - : { automations: parseOrcadMigrationDormantAutomations(record.automations) }), - ...(record.automationRuns === undefined - ? {} - : { automationRuns: parseOrcadMigrationDormantAutomationRuns(record.automationRuns) }), - ...(record.clientState === undefined - ? {} - : { clientState: parseOrcadMigrationClientState(record.clientState) }) - } - assertUnique(payload.worktreeMeta, (entry) => entry.worktreeId, 'worktree_meta') - assertUnique(payload.worktreeMeta, (entry) => entry.sourceKey, 'worktree_meta_source') - assertUnique(payload.worktreeLineage, (entry) => entry.worktreeId, 'worktree_lineage') - assertUnique(payload.worktreeLineage, (entry) => entry.sourceKey, 'worktree_lineage_source') - assertUnique(payload.workspaceLineage, (entry) => entry.childWorkspaceKey, 'workspace_lineage') - assertUnique(payload.workspaceLineage, (entry) => entry.sourceKey, 'workspace_lineage_source') - assertUnique(payload.sparsePresets, (entry) => `${entry.repoId}\0${entry.id}`, 'sparse_preset') - assertUnique(payload.retiredWorktreeNames, (entry) => entry.repoId, 'retired_names') - assertUnique( - payload.retiredWorktreeNamespaces, - (entry) => entry.namespaceKey, - 'retirement_namespace' - ) - return payload -} - -export function assertOrcadMigrationDormantStateReferences(args: { - dormantState: OrcadMigrationDormantStatePayload - repositoryIds: ReadonlySet - folderWorkspaceIds: ReadonlySet -}): void { - const owns = (value: string): boolean => { - const parsed = parseWorkspaceKey(value) - if (parsed?.type === 'folder') { - return args.folderWorkspaceIds.has(parsed.folderWorkspaceId) - } - const worktreeId = parsed?.type === 'worktree' ? parsed.worktreeId : value - return args.repositoryIds.has(getRepoIdFromWorktreeId(worktreeId)) - } - for (const entry of args.dormantState.worktreeMeta) { - if (!owns(entry.worktreeId) || entry.meta.hostId !== 'local') { - throw new Error('orcad_migration_dormant_worktree_meta_scope_invalid') - } - } - for (const entry of args.dormantState.worktreeLineage) { - if ( - entry.worktreeId !== entry.lineage.worktreeId || - !owns(entry.worktreeId) || - !owns(entry.lineage.parentWorktreeId) - ) { - throw new Error('orcad_migration_dormant_worktree_lineage_scope_invalid') - } - } - for (const entry of args.dormantState.workspaceLineage) { - if ( - entry.childWorkspaceKey !== entry.lineage.childWorkspaceKey || - !owns(entry.childWorkspaceKey) || - !owns(entry.lineage.parentWorkspaceKey) - ) { - throw new Error('orcad_migration_dormant_workspace_lineage_scope_invalid') - } - } - for (const preset of args.dormantState.sparsePresets) { - if (!args.repositoryIds.has(preset.repoId)) { - throw new Error('orcad_migration_dormant_sparse_preset_scope_invalid') - } - } - for (const entry of args.dormantState.retiredWorktreeNames) { - if (!args.repositoryIds.has(entry.repoId)) { - throw new Error('orcad_migration_dormant_retired_names_scope_invalid') - } - } - for (const entry of args.dormantState.retiredWorktreeNamespaces) { - if ( - !entry.namespaceKey.startsWith('local:') || - entry.sourceNamespaceKeys.some((key) => !key.startsWith('ssh:')) - ) { - throw new Error('orcad_migration_dormant_retirement_namespace_scope_invalid') - } - } - if (args.dormantState.workspaceSession) { - assertOrcadMigrationDormantWorkspaceSessionReferences({ - session: args.dormantState.workspaceSession, - owns, - repositoryIds: args.repositoryIds - }) - } - assertOrcadMigrationScrollbackReferences( - args.dormantState.workspaceSession, - args.dormantState.terminalScrollbackSnapshots ?? [] - ) - assertOrcadMigrationDormantAutomationReferences({ - automations: args.dormantState.automations ?? [], - automationRuns: args.dormantState.automationRuns ?? [], - owns, - repositoryIds: args.repositoryIds - }) - assertOrcadMigrationClientStateReferences({ - clientState: args.dormantState.clientState, - repositoryIds: args.repositoryIds, - owns - }) -} diff --git a/src/shared/orcad-migration-dormant-value-validation.ts b/src/shared/orcad-migration-dormant-value-validation.ts deleted file mode 100644 index beb7ea136f4a..000000000000 --- a/src/shared/orcad-migration-dormant-value-validation.ts +++ /dev/null @@ -1,100 +0,0 @@ -export const MAX_ORCAD_MIGRATION_DORMANT_ROWS = 16_384 -export const MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES = 256 - -export function boundedArray( - value: unknown, - parse: (entry: unknown) => T, - label: string, - maximum = MAX_ORCAD_MIGRATION_DORMANT_ROWS -): T[] { - if (!Array.isArray(value)) { - throw new Error(`orcad_migration_dormant_${label}_invalid`) - } - if (value.length > maximum) { - throw new Error(`orcad_migration_dormant_${label}_too_many`) - } - return value.map(parse) -} - -export function stringArray(value: unknown, maximum = MAX_ORCAD_MIGRATION_DORMANT_ROWS): string[] { - if (!Array.isArray(value) || value.length > maximum) { - throw new Error('orcad_migration_dormant_string_array_invalid') - } - const result = value.filter(isString) - if ( - result.length !== value.length || - result.some((entry) => !entry) || - new Set(result).size !== result.length - ) { - throw new Error('orcad_migration_dormant_string_array_invalid') - } - return result -} - -export function assertUnique(values: T[], key: (value: T) => string, label: string): void { - const keys = values.map(key) - if (new Set(keys).size !== keys.length) { - throw new Error(`orcad_migration_dormant_${label}_duplicate`) - } -} - -export function requiredRecord(value: unknown, error: string): Record { - if (!isRecordValue(value)) { - throw new Error(error) - } - return value -} - -function isRecordValue(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -export function requiredString(value: unknown, error: string): string { - if (typeof value !== 'string' || !value) { - throw new Error(error) - } - return value -} - -export function requiredStringOrEmpty(value: unknown, error: string): string { - if (typeof value !== 'string') { - throw new Error(error) - } - return value -} - -export function optionalNullableString(value: unknown, error: string): string | null | undefined { - if (value !== undefined && value !== null && typeof value !== 'string') { - throw new Error(error) - } - return value -} - -export function requiredBoolean(value: unknown, error: string): void { - if (typeof value !== 'boolean') { - throw new Error(error) - } -} - -export function requiredFinite(value: unknown, error: string): void { - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new Error(error) - } -} - -export function optionalFinite(value: unknown, error: string, nullable = false): void { - if (value === undefined || (nullable && value === null)) { - return - } - requiredFinite(value, error) -} - -export function optionalPositiveInteger(value: unknown, error: string): void { - if (value !== undefined && (!Number.isSafeInteger(value) || Number(value) < 1)) { - throw new Error(error) - } -} - -export function isString(value: unknown): value is string { - return typeof value === 'string' -} diff --git a/src/shared/orcad-migration-manifest-contract.test.ts b/src/shared/orcad-migration-manifest-contract.test.ts deleted file mode 100644 index 0a2ea6d6bbaa..000000000000 --- a/src/shared/orcad-migration-manifest-contract.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest -} from './orcad-migration-manifest' - -const digest = 'a'.repeat(64) - -function manifest(overrides: { version?: unknown; dormantState?: unknown } = {}) { - return { - version: overrides.version ?? ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: 'migration-contract', - createdAt: '2026-10-01T12:00:00.000Z', - source: { sshTargetId: 'target', sshTargetGeneration: 3, targetLabel: 'Host' }, - payload: { - repositories: [ - { - id: 'repo-1', - path: '/srv/repo-1', - displayName: 'Repo', - badgeColor: '#737373', - addedAt: 1 - } - ], - projectGroups: [ - { - id: 'group-1', - name: 'Folders', - tabOrder: 0, - createdAt: 1, - updatedAt: 1, - parentPath: null, - parentGroupId: null, - isCollapsed: false, - color: null, - createdFrom: 'manual' - } - ], - folderWorkspaces: [ - { - id: 'folder-1', - projectGroupId: 'group-1', - name: 'Notes', - folderPath: '/srv/notes', - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1, - isArchived: false, - isUnread: false, - isPinned: false - } - ], - ...(overrides.dormantState === undefined ? {} : { dormantState: overrides.dormantState }) - }, - manifestSha256: digest - } -} - -function dormantLineage(childWorkspaceKey: string, parentWorkspaceKey: string) { - const lineage = { - childWorkspaceKey, - parentWorkspaceKey, - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt: 1 - } - return { - version: 1, - worktreeMeta: [], - worktreeLineage: [], - workspaceLineage: [{ sourceKey: childWorkspaceKey, childWorkspaceKey, lineage }], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [] - } -} - -describe('orcad migration manifest contract', () => { - it('is an explicit version 1 artifact', () => { - expect(ORCAD_MIGRATION_MANIFEST_VERSION).toBe(1) - expect(parseOrcadMigrationManifest(manifest()).version).toBe(1) - }) - - it.each([2, 10, 0, '1', undefined, null])( - 'rejects manifest version %j instead of guessing its shape', - (version) => { - expect(() => parseOrcadMigrationManifest({ ...manifest(), version })).toThrow( - 'orcad_migration_manifest_version_unsupported' - ) - } - ) - - it('validates folder workspace keys alongside worktree workspace keys', () => { - const parsed = parseOrcadMigrationManifest( - manifest({ dormantState: dormantLineage('folder:folder-1', 'worktree:repo-1::/srv/repo-1') }) - ) - expect(parsed.payload.dormantState?.workspaceLineage[0]?.lineage).toMatchObject({ - childWorkspaceKey: 'folder:folder-1', - parentWorkspaceKey: 'worktree:repo-1::/srv/repo-1', - childInstanceId: null, - parentInstanceId: null - }) - }) - - it.each([ - ['a folder workspace outside the catalog', 'folder:folder-2'], - ['a worktree outside the catalog', 'worktree:repo-2::/srv/repo-2'] - ])('refuses lineage that names %s', (_label, key) => { - expect(() => - parseOrcadMigrationManifest( - manifest({ dormantState: dormantLineage(key, 'folder:folder-1') }) - ) - ).toThrow('orcad_migration_dormant_workspace_lineage_scope_invalid') - }) - - it('rejects a malformed workspace key rather than treating it as a worktree id', () => { - expect(() => - parseOrcadMigrationManifest( - manifest({ dormantState: dormantLineage('folder:', 'folder:folder-1') }) - ) - ).toThrow('orcad_migration_dormant_lineage_workspace_key_invalid') - }) -}) diff --git a/src/shared/orcad-migration-manifest-fields.ts b/src/shared/orcad-migration-manifest-fields.ts deleted file mode 100644 index 3a038fa0bd81..000000000000 --- a/src/shared/orcad-migration-manifest-fields.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Field checks shared by the manifest and receipt parsers; each throws a labeled error. - -export function boundedArray( - value: unknown, - maximum: number, - parse: (entry: unknown) => T, - label: string -): T[] { - if (!Array.isArray(value)) { - throw new Error(`orcad_migration_manifest_${label}_invalid`) - } - if (value.length > maximum) { - throw new Error(`orcad_migration_manifest_${label}_too_many`) - } - return value.map(parse) -} - -export function assertUniqueIds(values: { id: string }[], label: string): void { - const ids = new Set() - for (const value of values) { - if (ids.has(value.id)) { - throw new Error(`orcad_migration_manifest_${label}_duplicate_id`) - } - ids.add(value.id) - } -} - -export function boundedStringArray(value: unknown, maximum: number, label: string): string[] { - if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string' && entry)) { - throw new Error(`${label}_invalid`) - } - if (value.length > maximum || new Set(value).size !== value.length) { - throw new Error(`${label}_invalid`) - } - return [...value] -} - -export function requiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || value.length === 0) { - throw new Error(`${label}_invalid`) - } - return value -} - -export function requiredFiniteNumber(value: unknown, label: string): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new Error(`${label}_invalid`) - } - return value -} - -export function requiredDate(value: unknown, label: string): string { - const result = requiredString(value, label) - if (!Number.isFinite(Date.parse(result))) { - throw new Error(`${label}_invalid`) - } - return result -} - -export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/shared/orcad-migration-manifest-validation.ts b/src/shared/orcad-migration-manifest-validation.ts deleted file mode 100644 index 8272463c5d0e..000000000000 --- a/src/shared/orcad-migration-manifest-validation.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { FolderWorkspace } from './folder-workspace-types' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationImportReceipt, - OrcadMigrationManifest, - OrcadMigrationManifestSource -} from './orcad-migration-manifest' -import type { ProjectGroup } from './project-group-types' -import type { Repo } from './repo-types' -import { - assertUniqueIds, - boundedArray, - boundedStringArray, - isRecord, - requiredDate, - requiredFiniteNumber, - requiredString -} from './orcad-migration-manifest-fields' -import { - assertOrcadMigrationDormantStateReferences, - parseOrcadMigrationDormantState -} from './orcad-migration-dormant-state-validation' - -export const ORCAD_MIGRATION_MANIFEST_VERSION = 1 as const -export type OrcadMigrationManifestVersion = typeof ORCAD_MIGRATION_MANIFEST_VERSION -export const MAX_ORCAD_MIGRATION_MANIFEST_BYTES = 768 * 1024 -export const MAX_ORCAD_MIGRATION_REPOSITORIES = 1_024 -export const MAX_ORCAD_MIGRATION_PROJECT_GROUPS = 4_096 -export const MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES = 16_384 -export const MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS = 64 - -export function parseOrcadMigrationManifest(value: unknown): OrcadMigrationManifest { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_invalid') - } - if (value.version !== ORCAD_MIGRATION_MANIFEST_VERSION) { - throw new Error('orcad_migration_manifest_version_unsupported') - } - const migrationId = requiredString(value.migrationId, 'migrationId') - const createdAt = requiredDate(value.createdAt, 'createdAt') - const source = parseSource(value.source) - const payload = parsePayload(value.payload) - const manifestSha256 = requiredString(value.manifestSha256, 'manifestSha256') - if (!/^[a-f0-9]{64}$/.test(manifestSha256)) { - throw new Error('orcad_migration_manifest_digest_invalid') - } - const manifest: OrcadMigrationManifest = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId, - createdAt, - source, - payload, - ...(value.destinationEnvironmentId === undefined - ? {} - : { - destinationEnvironmentId: requiredString( - value.destinationEnvironmentId, - 'destinationEnvironmentId' - ) - }), - manifestSha256 - } - const bytes = new TextEncoder().encode(JSON.stringify(manifest)).byteLength - if (bytes > MAX_ORCAD_MIGRATION_MANIFEST_BYTES) { - throw new Error('orcad_migration_manifest_too_large') - } - return manifest -} - -export function normalizeOrcadMigrationImportReceipts( - value: unknown -): OrcadMigrationImportReceipt[] { - if (!Array.isArray(value)) { - return [] - } - const receipts: OrcadMigrationImportReceipt[] = [] - const seen = new Set() - for (let index = value.length - 1; index >= 0; index--) { - try { - const receipt = parseReceipt(value[index]) - if (!seen.has(receipt.migrationId)) { - seen.add(receipt.migrationId) - receipts.push(receipt) - if (receipts.length === MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS) { - break - } - } - } catch { - // Invalid receipts cannot prove an import and are dropped fail-closed. - } - } - // Keep persisted receipts readable in the documented Node 18 rollback slot. - return receipts.reduceRight((reversed, receipt) => { - reversed.push(receipt) - return reversed - }, []) -} - -function parsePayload(value: unknown): OrcadMigrationCatalogPayload { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_payload_invalid') - } - const repositories = boundedArray( - value.repositories, - MAX_ORCAD_MIGRATION_REPOSITORIES, - parseRepository, - 'repositories' - ) - const projectGroups = boundedArray( - value.projectGroups, - MAX_ORCAD_MIGRATION_PROJECT_GROUPS, - parseProjectGroup, - 'projectGroups' - ) - const folderWorkspaces = boundedArray( - value.folderWorkspaces, - MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES, - parseFolderWorkspace, - 'folderWorkspaces' - ) - assertUniqueIds(repositories, 'repositories') - assertUniqueIds(projectGroups, 'projectGroups') - assertUniqueIds(folderWorkspaces, 'folderWorkspaces') - const dormantState = - value.dormantState === undefined - ? undefined - : parseOrcadMigrationDormantState(value.dormantState) - if (dormantState) { - assertOrcadMigrationDormantStateReferences({ - dormantState, - repositoryIds: new Set(repositories.map((repo) => repo.id)), - folderWorkspaceIds: new Set(folderWorkspaces.map((workspace) => workspace.id)) - }) - } - return { - repositories, - projectGroups, - folderWorkspaces, - ...(dormantState ? { dormantState } : {}) - } -} - -function parseSource(value: unknown): OrcadMigrationManifestSource { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_source_invalid') - } - const generation = value.sshTargetGeneration - if (generation !== null && (!Number.isSafeInteger(generation) || Number(generation) < 1)) { - throw new Error('orcad_migration_manifest_source_generation_invalid') - } - return { - sshTargetId: requiredString(value.sshTargetId, 'source.sshTargetId'), - sshTargetGeneration: generation === null ? null : Number(generation), - targetLabel: requiredString(value.targetLabel, 'source.targetLabel') - } -} - -function parseRepository(value: unknown): Repo { - const copy = structuredClone(value) - if (!isMigratedRepository(copy)) { - throw new Error('orcad_migration_manifest_repository_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedRepository(value: unknown): value is Repo { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_repository_invalid') - } - requiredString(value.id, 'repository.id') - requiredString(value.path, 'repository.path') - requiredString(value.displayName, 'repository.displayName') - requiredString(value.badgeColor, 'repository.badgeColor') - requiredFiniteNumber(value.addedAt, 'repository.addedAt') - if (value.kind !== undefined && value.kind !== 'git' && value.kind !== 'folder') { - throw new Error('orcad_migration_manifest_repository_kind_invalid') - } - return true -} - -function parseProjectGroup(value: unknown): ProjectGroup { - const copy = structuredClone(value) - if (!isMigratedProjectGroup(copy)) { - throw new Error('orcad_migration_manifest_project_group_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedProjectGroup(value: unknown): value is ProjectGroup { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_project_group_invalid') - } - requiredString(value.id, 'projectGroup.id') - requiredString(value.name, 'projectGroup.name') - requiredFiniteNumber(value.tabOrder, 'projectGroup.tabOrder') - requiredFiniteNumber(value.createdAt, 'projectGroup.createdAt') - requiredFiniteNumber(value.updatedAt, 'projectGroup.updatedAt') - if (value.parentPath !== null && typeof value.parentPath !== 'string') { - throw new Error('orcad_migration_manifest_project_group_parent_path_invalid') - } - if (value.parentGroupId !== null && typeof value.parentGroupId !== 'string') { - throw new Error('orcad_migration_manifest_project_group_parent_invalid') - } - if (typeof value.isCollapsed !== 'boolean') { - throw new Error('orcad_migration_manifest_project_group_collapsed_invalid') - } - if (value.color !== null && typeof value.color !== 'string') { - throw new Error('orcad_migration_manifest_project_group_color_invalid') - } - if (!['manual', 'folder-scan', 'migration'].includes(String(value.createdFrom))) { - throw new Error('orcad_migration_manifest_project_group_origin_invalid') - } - return true -} - -function parseFolderWorkspace(value: unknown): FolderWorkspace { - const copy = structuredClone(value) - if (!isMigratedFolderWorkspace(copy)) { - throw new Error('orcad_migration_manifest_folder_workspace_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedFolderWorkspace(value: unknown): value is FolderWorkspace { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_folder_workspace_invalid') - } - requiredString(value.id, 'folderWorkspace.id') - requiredString(value.projectGroupId, 'folderWorkspace.projectGroupId') - requiredString(value.name, 'folderWorkspace.name') - requiredString(value.folderPath, 'folderWorkspace.folderPath') - requiredFiniteNumber(value.sortOrder, 'folderWorkspace.sortOrder') - requiredFiniteNumber(value.lastActivityAt, 'folderWorkspace.lastActivityAt') - requiredFiniteNumber(value.createdAt, 'folderWorkspace.createdAt') - requiredFiniteNumber(value.updatedAt, 'folderWorkspace.updatedAt') - for (const key of ['isArchived', 'isUnread', 'isPinned'] as const) { - if (typeof value[key] !== 'boolean') { - throw new Error(`orcad_migration_manifest_folder_workspace_${key}_invalid`) - } - } - return true -} - -function parseReceipt(value: unknown): OrcadMigrationImportReceipt { - if (!isRecord(value) || value.version !== ORCAD_MIGRATION_MANIFEST_VERSION) { - throw new Error('orcad_migration_receipt_invalid') - } - const manifestSha256 = requiredString(value.manifestSha256, 'receipt.manifestSha256') - if (!/^[a-f0-9]{64}$/.test(manifestSha256)) { - throw new Error('orcad_migration_receipt_digest_invalid') - } - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: requiredString(value.migrationId, 'receipt.migrationId'), - manifestSha256, - source: parseSource(value.source), - importedAt: requiredDate(value.importedAt, 'receipt.importedAt'), - repositoryIds: boundedStringArray( - value.repositoryIds, - MAX_ORCAD_MIGRATION_REPOSITORIES, - 'receipt.repositoryIds' - ), - projectGroupIds: boundedStringArray( - value.projectGroupIds, - MAX_ORCAD_MIGRATION_PROJECT_GROUPS, - 'receipt.projectGroupIds' - ), - folderWorkspaceIds: boundedStringArray( - value.folderWorkspaceIds, - MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES, - 'receipt.folderWorkspaceIds' - ) - } -} diff --git a/src/shared/orcad-migration-manifest.test.ts b/src/shared/orcad-migration-manifest.test.ts deleted file mode 100644 index f677d54ff227..000000000000 --- a/src/shared/orcad-migration-manifest.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from './constants' -import { - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES, - MAX_ORCAD_MIGRATION_DORMANT_ROWS, - MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS, - MAX_ORCAD_MIGRATION_MANIFEST_BYTES, - normalizeOrcadMigrationImportReceipts, - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest, - serializeOrcadMigrationValue, - type OrcadMigrationImportReceipt -} from './orcad-migration-manifest' - -function receipt(index: number, overrides: Partial = {}) { - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: `migration-${index}`, - manifestSha256: index.toString(16).padStart(64, '0'), - source: { - sshTargetId: 'ssh-prod', - sshTargetGeneration: 1, - targetLabel: 'Production' - }, - importedAt: new Date(index * 1_000).toISOString(), - repositoryIds: [], - projectGroupIds: [], - folderWorkspaceIds: [], - ...overrides - } -} - -describe('orcad migration manifest', () => { - it('serializes object keys deterministically without locale ordering', () => { - expect( - serializeOrcadMigrationValue({ z: 1, Z: 2, nested: { ä: 3, a: 4 }, omitted: undefined }) - ).toBe('{"Z":2,"nested":{"a":4,"ä":3},"z":1}') - }) - - it('keeps the newest bounded set of valid unique receipts', () => { - const candidates: unknown[] = Array.from( - { length: MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS + 2 }, - (_, index) => receipt(index) - ) - candidates.push(receipt(10, { source: { ...receipt(10).source, targetLabel: 'Newest' } })) - candidates.push({ invalid: true }) - - const normalized = normalizeOrcadMigrationImportReceipts(candidates) - - expect(normalized).toHaveLength(MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS) - expect(normalized.some((entry) => entry.migrationId === 'migration-0')).toBe(false) - expect( - normalized.find((entry) => entry.migrationId === 'migration-10')?.source.targetLabel - ).toBe('Newest') - }) - - it('normalizes receipts without Node 20-only array methods', () => { - const prototype: object = Array.prototype - const descriptor = Object.getOwnPropertyDescriptor(prototype, 'toReversed') - try { - Object.defineProperty(prototype, 'toReversed', { - configurable: true, - value: undefined, - writable: true - }) - expect(normalizeOrcadMigrationImportReceipts([receipt(1), receipt(2)])).toHaveLength(2) - } finally { - if (descriptor) { - Object.defineProperty(prototype, 'toReversed', descriptor) - } else { - Reflect.deleteProperty(prototype, 'toReversed') - } - } - }) - - it('drops a receipt with duplicate catalog identities', () => { - expect( - normalizeOrcadMigrationImportReceipts([receipt(1, { repositoryIds: ['repo-1', 'repo-1'] })]) - ).toEqual([]) - }) - - it('accepts bounded dormant state and rejects rows outside the catalog scope', () => { - const candidate = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: 'migration-dormant', - createdAt: '2026-08-30T12:00:00.000Z', - source: receipt(1).source, - payload: { - repositories: [ - { - id: 'repo-1', - path: '/srv/repo-1', - displayName: 'Repo', - badgeColor: '#737373', - addedAt: 1 - } - ], - projectGroups: [], - folderWorkspaces: [], - dormantState: { - version: 1, - worktreeMeta: [ - { - sourceKey: 'repo-1::/srv/worktree', - worktreeId: 'repo-1::/srv/worktree', - meta: { - displayName: '', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1, - lastActivityAt: 1, - hostId: 'local' - } - } - ], - worktreeLineage: [], - workspaceLineage: [], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [], - workspaceSession: { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - 'repo-1::/srv/worktree': [ - { - id: 'tab-dormant', - ptyId: null, - worktreeId: 'repo-1::/srv/worktree', - title: 'Dormant terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } - } - } - }, - manifestSha256: 'a'.repeat(64) - } - - expect(parseOrcadMigrationManifest(candidate).payload.dormantState?.worktreeMeta).toHaveLength( - 1 - ) - expect( - parseOrcadMigrationManifest(candidate).payload.dormantState?.workspaceSession?.tabsByWorktree - ).toHaveProperty('repo-1::/srv/worktree') - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - worktreeMeta: [ - { - ...candidate.payload.dormantState.worktreeMeta[0], - worktreeId: 'repo-other::/srv/worktree' - } - ] - } - } - }) - ).toThrow('orcad_migration_dormant_worktree_meta_scope_invalid') - - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - workspaceSession: { - ...candidate.payload.dormantState.workspaceSession, - tabsByWorktree: { - 'repo-1::/srv/worktree': [ - { - ...candidate.payload.dormantState.workspaceSession.tabsByWorktree[ - 'repo-1::/srv/worktree' - ][0], - ptyId: 'pty-live' - } - ] - } - } - } - } - }) - ).toThrow('orcad_migration_dormant_workspace_session_pty_invalid') - - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - worktreeMeta: Array.from( - { length: MAX_ORCAD_MIGRATION_DORMANT_ROWS + 1 }, - () => candidate.payload.dormantState.worktreeMeta[0] - ) - } - } - }) - ).toThrow('orcad_migration_dormant_worktree_meta_too_many') - - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - retiredWorktreeNamespaces: Array.from( - { length: MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES + 1 }, - () => ({ - sourceNamespaceKeys: ['ssh:source:/srv/orca-worktrees'], - namespaceKey: 'local:/srv/orca-worktrees', - registry: { exhaustedTiers: 0, names: ['nautilus'] } - }) - ) - } - } - }) - ).toThrow('orcad_migration_dormant_retirement_namespaces_too_many') - - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - worktreeMeta: [ - { - ...candidate.payload.dormantState.worktreeMeta[0], - meta: { - ...candidate.payload.dormantState.worktreeMeta[0].meta, - comment: 'x'.repeat(MAX_ORCAD_MIGRATION_MANIFEST_BYTES) - } - } - ] - } - } - }) - ).toThrow('orcad_migration_manifest_too_large') - }) - - it('accepts only destination-owned disabled automations with final run history', () => { - const automation = { - id: 'automation-1', - name: 'Nightly checks', - prompt: 'Run tests', - precheck: null, - agentId: 'codex', - runContext: { - kind: 'workspace-run', - projectId: 'repo:repo-1', - hostId: 'local', - projectHostSetupId: 'repo-1', - repoId: 'repo-1', - path: '/srv/repo-1' - }, - sourceContext: null, - projectId: 'repo-1', - executionTargetType: 'local', - executionTargetId: 'local', - schedulerOwner: 'remote_host_service', - workspaceMode: 'new_per_run', - workspaceId: null, - baseBranch: 'main', - reuseSession: false, - timezone: 'UTC', - rrule: 'FREQ=DAILY', - dtstart: 1, - enabled: false, - nextRunAt: 2, - missedRunPolicy: 'run_once_within_grace', - missedRunGraceMinutes: 60, - createdAt: 1, - updatedAt: 2 - } - const run = { - id: 'run-1', - automationId: automation.id, - runContext: automation.runContext, - sourceContext: null, - title: 'Nightly checks run 1', - scheduledFor: 1, - status: 'completed', - trigger: 'scheduled', - workspaceId: null, - sessionKind: 'terminal', - chatSessionId: null, - terminalSessionId: 'tab-history', - terminalPaneKey: null, - terminalPtyId: 'pty-history', - outputSnapshot: { - format: 'plain_text', - content: 'all green', - capturedAt: 2, - truncated: false - }, - precheckResult: null, - usage: null, - error: null, - startedAt: 1, - dispatchedAt: 1, - createdAt: 1 - } - const candidate = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: 'migration-automation', - createdAt: '2026-08-30T12:00:00.000Z', - source: receipt(1).source, - payload: { - repositories: [ - { - id: 'repo-1', - path: '/srv/repo-1', - displayName: 'Repo', - badgeColor: '#737373', - addedAt: 1 - } - ], - projectGroups: [], - folderWorkspaces: [], - dormantState: { - version: 1, - worktreeMeta: [], - worktreeLineage: [], - workspaceLineage: [], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [], - automations: [automation], - automationRuns: [run] - } - }, - manifestSha256: 'a'.repeat(64) - } - - expect( - parseOrcadMigrationManifest(candidate).payload.dormantState?.automationRuns?.[0] - ?.outputSnapshot?.content - ).toBe('all green') - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - automations: [{ ...automation, enabled: true }] - } - } - }) - ).toThrow('orcad_migration_dormant_automation_owner_invalid') - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - automationRuns: [{ ...run, status: 'dispatching' }] - } - } - }) - ).toThrow('orcad_migration_dormant_automation_run_status_invalid') - }) -}) diff --git a/src/shared/orcad-migration-manifest.ts b/src/shared/orcad-migration-manifest.ts deleted file mode 100644 index 9a0d1eab1ec5..000000000000 --- a/src/shared/orcad-migration-manifest.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { FolderWorkspace } from './folder-workspace-types' -import type { Automation, AutomationRun } from './automations-types' -import type { OrcadMigrationManifestVersion } from './orcad-migration-manifest-validation' -import type { ProjectGroup } from './project-group-types' -import type { Repo } from './repo-types' -import type { SparsePreset } from './worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from './worktree/lineage-types' -import type { WorktreeMeta } from './worktree/meta-types' -import type { RetiredNameRegistry } from './worktree/retired-name-registry' -import type { WorkspaceSessionState } from './workspace-session-state-types' -import type { OrcadMigrationClientStatePayload } from './orcad-migration-client-state' -import type { - OrcadMigrationSnapshotUploadState, - OrcadMigrationTerminalScrollbackSnapshot -} from './orcad-migration-scrollback' - -export { - MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES, - MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS, - MAX_ORCAD_MIGRATION_MANIFEST_BYTES, - MAX_ORCAD_MIGRATION_PROJECT_GROUPS, - MAX_ORCAD_MIGRATION_REPOSITORIES, - normalizeOrcadMigrationImportReceipts, - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest -} from './orcad-migration-manifest-validation' -export { - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES, - MAX_ORCAD_MIGRATION_DORMANT_ROWS, - ORCAD_MIGRATION_DORMANT_STATE_VERSION -} from './orcad-migration-dormant-state-validation' -export { - MAX_ORCAD_MIGRATION_STAGED_CATALOGS, - normalizeOrcadMigrationStagedCatalogs -} from './orcad-migration-staged-catalog-validation' - -export type OrcadMigrationManifestSource = { - sshTargetId: string - sshTargetGeneration: number | null - targetLabel: string -} - -export type OrcadMigrationCatalogPayload = { - repositories: Repo[] - projectGroups: ProjectGroup[] - folderWorkspaces: FolderWorkspace[] - dormantState?: OrcadMigrationDormantStatePayload -} - -export type OrcadMigrationDormantWorktreeMeta = { - sourceKey: string - worktreeId: string - meta: WorktreeMeta -} - -export type OrcadMigrationDormantWorktreeLineage = { - sourceKey: string - worktreeId: string - lineage: WorktreeLineage -} - -export type OrcadMigrationDormantWorkspaceLineage = { - sourceKey: string - childWorkspaceKey: string - lineage: WorkspaceLineage -} - -export type OrcadMigrationDormantRetiredNames = { - repoId: string - registry: RetiredNameRegistry -} - -export type OrcadMigrationDormantRetirementNamespace = { - sourceNamespaceKeys: string[] - namespaceKey: string - registry: RetiredNameRegistry -} - -export type OrcadMigrationDormantStatePayload = { - version: 1 - worktreeMeta: OrcadMigrationDormantWorktreeMeta[] - worktreeLineage: OrcadMigrationDormantWorktreeLineage[] - workspaceLineage: OrcadMigrationDormantWorkspaceLineage[] - sparsePresets: SparsePreset[] - retiredWorktreeNames: OrcadMigrationDormantRetiredNames[] - retiredWorktreeNamespaces: OrcadMigrationDormantRetirementNamespace[] - workspaceSession?: WorkspaceSessionState - terminalScrollbackSnapshots?: OrcadMigrationTerminalScrollbackSnapshot[] - automations?: Automation[] - automationRuns?: AutomationRun[] - /** Client-owned durable intent projected to the destination authority. */ - clientState?: OrcadMigrationClientStatePayload -} - -export type OrcadMigrationManifest = { - version: OrcadMigrationManifestVersion - migrationId: string - createdAt: string - source: OrcadMigrationManifestSource - payload: OrcadMigrationCatalogPayload - /** Destination runtime identity used to re-key desktop routing after commit. */ - destinationEnvironmentId?: string - manifestSha256: string -} - -export type OrcadMigrationImportReceipt = { - version: OrcadMigrationManifestVersion - migrationId: string - manifestSha256: string - source: OrcadMigrationManifestSource - importedAt: string - repositoryIds: string[] - projectGroupIds: string[] - folderWorkspaceIds: string[] -} - -export type OrcadMigrationImportResult = { - status: 'imported' | 'already-imported' - receipt: OrcadMigrationImportReceipt -} - -export type OrcadMigrationStagedCatalog = { - version: OrcadMigrationManifestVersion - manifest: OrcadMigrationManifest - stagedAt: string -} - -export type OrcadMigrationCatalogState = - | { - state: 'absent' - migrationId: string - manifestSha256: string - } - | { - state: 'staged' - migrationId: string - manifestSha256: string - stagedAt: string - snapshotUploads?: OrcadMigrationSnapshotUploadState[] - } - | { - state: 'committed' - migrationId: string - manifestSha256: string - receipt: OrcadMigrationImportReceipt - } - -export type OrcadMigrationCatalogAbortResult = OrcadMigrationCatalogState & { - aborted: boolean - durableAbsent?: true -} - -export function orcadMigrationManifestHashInput( - manifest: Omit -): string { - return serializeOrcadMigrationValue(manifest) -} - -export function serializeOrcadMigrationValue(value: unknown): string { - return JSON.stringify(canonicalizeJsonValue(value)) -} - -function canonicalizeJsonValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(canonicalizeJsonValue) - } - if (!isRecord(value)) { - return value - } - return Object.fromEntries( - Object.entries(value) - .filter(([, entry]) => entry !== undefined) - .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) - .map(([key, entry]) => [key, canonicalizeJsonValue(entry)]) - ) -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/shared/orcad-migration-preflight.ts b/src/shared/orcad-migration-preflight.ts deleted file mode 100644 index 9a3f84d46a4f..000000000000 --- a/src/shared/orcad-migration-preflight.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { FolderWorkspace } from './folder-workspace-types' -import type { Repo } from './repo-types' -import type { SavedPortForward, SshRemotePtyLease, SshTarget } from './ssh-types' - -export type OrcadMigrationBlockerCategory = - | 'registration' - | 'exclusive-ownership' - | 'drainable-static-state' - | 'live-or-unverifiable' - | 'client-owned-state' - -export const ORCAD_MIGRATION_DEPENDENCY_KINDS = [ - 'saved-port-forward', - 'terminal-lease', - 'terminal-recovery', - 'workspace-session', - 'worktree-metadata', - 'worktree-lineage', - 'workspace-lineage', - 'sparse-preset', - 'retired-worktree-name', - 'automation', - 'automation-run', - 'mobile-tab-selection', - 'ui-routing' -] as const - -export type OrcadMigrationDependencyKind = (typeof ORCAD_MIGRATION_DEPENDENCY_KINDS)[number] - -export type OrcadMigrationDependency = { - kind: OrcadMigrationDependencyKind - count: number -} - -export type OrcadMigrationRepository = Pick -export type OrcadMigrationFolderWorkspace = Pick -export type OrcadMigrationTerminalLease = Pick< - SshRemotePtyLease, - 'ptyId' | 'worktreeId' | 'tabId' | 'leafId' | 'state' | 'updatedAt' -> - -export type OrcadMigrationBlocker = - | { - code: 'orcad_migration_target_not_found' - category: 'registration' - } - | { - code: 'orcad_migration_target_owned' - category: 'exclusive-ownership' - owner: NonNullable - } - | { - code: 'orcad_migration_direct_ssh_repositories' - category: 'drainable-static-state' - repositories: OrcadMigrationRepository[] - } - | { - code: 'orcad_migration_direct_ssh_folder_workspaces' - category: 'drainable-static-state' - folderWorkspaces: OrcadMigrationFolderWorkspace[] - } - | { - code: 'orcad_migration_direct_ssh_terminal_leases' - category: 'live-or-unverifiable' - terminalLeases: OrcadMigrationTerminalLease[] - } - | { - code: 'orcad_migration_saved_port_forwards' - category: 'client-owned-state' - portForwards: SavedPortForward[] - } - | { - code: 'orcad_migration_dependent_state' - category: 'client-owned-state' - dependencies: OrcadMigrationDependency[] - } - -export type OrcadMigrationPreflight = { - targetId: string - targetLabel: string | null - claimable: boolean - blockers: OrcadMigrationBlocker[] -} diff --git a/src/shared/orcad-migration-scrollback.test.ts b/src/shared/orcad-migration-scrollback.test.ts deleted file mode 100644 index 012b8538ff3e..000000000000 --- a/src/shared/orcad-migration-scrollback.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from './constants' -import { - assertOrcadMigrationScrollbackReferences, - MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS, - OrcadMigrationSnapshotChunkRequestSchema, - parseOrcadMigrationSnapshotChunkResult, - parseOrcadMigrationTerminalScrollbackSnapshots, - type OrcadMigrationSnapshotChunkRequest, - type OrcadMigrationTerminalScrollbackSnapshot -} from './orcad-migration-scrollback' -import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from './terminal-scrollback-limits' - -const DIGEST = 'a'.repeat(64) - -describe('orcad migration scrollback wire validation', () => { - it('accepts only canonical non-empty bounded base64 chunks', () => { - const request = chunkRequest('YQ==') - expect(OrcadMigrationSnapshotChunkRequestSchema.safeParse(request).success).toBe(true) - for (const bytesBase64 of ['', 'YQ', 'YQ=', 'A===', '***=']) { - expect( - OrcadMigrationSnapshotChunkRequestSchema.safeParse({ ...request, bytesBase64 }).success - ).toBe(false) - } - }) - - it('requires an acknowledgment for exactly the decoded chunk length', () => { - const request = chunkRequest(Buffer.from('abc').toString('base64'), 7) - expect( - parseOrcadMigrationSnapshotChunkResult( - { - migrationId: request.migrationId, - manifestSha256: request.manifestSha256, - ref: request.ref, - acknowledgedOffset: 10 - }, - request - ).acknowledgedOffset - ).toBe(10) - for (const acknowledgedOffset of [7, 9, 11]) { - expect(() => - parseOrcadMigrationSnapshotChunkResult( - { - migrationId: request.migrationId, - manifestSha256: request.manifestSha256, - ref: request.ref, - acknowledgedOffset - }, - request - ) - ).toThrow('orcad_migration_snapshot_chunk_result_invalid') - } - }) - - it('requires unique refs and tab/leaf identities', () => { - const first = descriptor(1) - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots([first, { ...descriptor(2), ref: first.ref }]) - ).toThrow('orcad_migration_dormant_scrollback_snapshot_ref_duplicate') - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots([ - first, - { ...descriptor(2), tabId: first.tabId, leafId: first.leafId } - ]) - ).toThrow('orcad_migration_dormant_scrollback_snapshot_leaf_duplicate') - }) - - it('enforces descriptor count, per-file, and aggregate limits', () => { - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots( - Array.from({ length: MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS + 1 }, (_, index) => - descriptor(index + 1) - ) - ) - ).toThrow('orcad_migration_dormant_scrollback_snapshots_too_many') - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots([ - { ...descriptor(1), byteLength: TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1 } - ]) - ).toThrow('orcad_migration_dormant_scrollback_size_invalid') - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots( - Array.from({ length: 52 }, (_, index) => ({ - ...descriptor(index + 1), - byteLength: TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT - })) - ) - ).toThrow('orcad_migration_dormant_scrollback_snapshots_too_large') - }) - - it('requires one exact descriptor for every published session ref', () => { - const session = getDefaultWorkspaceSession() - const first = descriptor(1) - session.terminalLayoutsByTabId = { - [first.tabId]: { - root: { type: 'leaf', leafId: first.leafId }, - activeLeafId: first.leafId, - expandedLeafId: null, - scrollbackRefsByLeafId: { [first.leafId]: first.ref } - } - } - expect(() => assertOrcadMigrationScrollbackReferences(session, [first])).not.toThrow() - expect(() => assertOrcadMigrationScrollbackReferences(session, [])).toThrow( - 'orcad_migration_dormant_scrollback_reference_invalid' - ) - expect(() => - assertOrcadMigrationScrollbackReferences(session, [{ ...first, ref: descriptor(2).ref }]) - ).toThrow('orcad_migration_dormant_scrollback_reference_invalid') - expect(() => assertOrcadMigrationScrollbackReferences(session, [first, descriptor(2)])).toThrow( - 'orcad_migration_dormant_scrollback_reference_invalid' - ) - }) -}) - -function chunkRequest(bytesBase64: string, offset = 0): OrcadMigrationSnapshotChunkRequest { - return { - migrationId: 'migration-1', - manifestSha256: DIGEST, - ref: `v1-${'1'.repeat(32)}`, - offset, - bytesBase64 - } -} - -function descriptor(index: number): OrcadMigrationTerminalScrollbackSnapshot { - const identity = index.toString(16).padStart(32, '0') - return { - tabId: `tab-${index}`, - leafId: `leaf-${index}`, - ref: `v1-${identity}`, - sha256: index.toString(16).padStart(64, '0'), - byteLength: 1 - } -} diff --git a/src/shared/orcad-migration-scrollback.ts b/src/shared/orcad-migration-scrollback.ts deleted file mode 100644 index 2b09e98b92d8..000000000000 --- a/src/shared/orcad-migration-scrollback.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { z } from 'zod' -import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from './terminal-scrollback-limits' -import type { WorkspaceSessionState } from './workspace-session-state-types' -import { - assertUnique, - boundedArray, - requiredRecord, - requiredString -} from './orcad-migration-dormant-value-validation' - -export const MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS = 512 -export const MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES = 256 * 1024 * 1024 -export const ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES = 128 * 1024 - -const Identity = z.string().min(1).max(256) -const Digest = z.string().regex(/^[a-f0-9]{64}$/) -const SnapshotRef = z.string().regex(/^v1-[a-f0-9]{32}$/) -const CanonicalBase64Chunk = z - .string() - .max(Math.ceil(ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES / 3) * 4) - .refine((value) => { - try { - return value.length > 0 && Buffer.from(value, 'base64').toString('base64') === value - } catch { - return false - } - }) - -export type OrcadMigrationTerminalScrollbackSnapshot = { - tabId: string - leafId: string - ref: string - sha256: string - byteLength: number -} - -export const OrcadMigrationSnapshotChunkRequestSchema = z - .object({ - migrationId: Identity, - manifestSha256: Digest, - ref: SnapshotRef, - offset: z.number().int().nonnegative().max(MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES), - bytesBase64: CanonicalBase64Chunk - }) - .strict() - -export type OrcadMigrationSnapshotChunkRequest = z.infer< - typeof OrcadMigrationSnapshotChunkRequestSchema -> - -export type OrcadMigrationSnapshotChunkResult = { - migrationId: string - manifestSha256: string - ref: string - acknowledgedOffset: number -} - -export type OrcadMigrationSnapshotUploadState = OrcadMigrationTerminalScrollbackSnapshot & { - receivedBytes: number -} - -export function parseOrcadMigrationSnapshotUploadStates( - value: unknown, - snapshots: readonly OrcadMigrationTerminalScrollbackSnapshot[] -): OrcadMigrationSnapshotUploadState[] | undefined { - if (snapshots.length === 0) { - return undefined - } - if (!Array.isArray(value) || value.length !== snapshots.length) { - throw new Error('orcad_migration_snapshot_transfer_unsupported') - } - return snapshots.map((expected, index) => { - const record = requiredRecord(value[index], 'orcad_migration_snapshot_state_invalid') - if ( - record.tabId !== expected.tabId || - record.leafId !== expected.leafId || - record.ref !== expected.ref || - record.sha256 !== expected.sha256 || - record.byteLength !== expected.byteLength || - !Number.isSafeInteger(record.receivedBytes) || - Number(record.receivedBytes) < 0 || - Number(record.receivedBytes) > expected.byteLength - ) { - throw new Error('orcad_migration_snapshot_state_invalid') - } - return { ...expected, receivedBytes: Number(record.receivedBytes) } - }) -} - -export function parseOrcadMigrationSnapshotChunkResult( - value: unknown, - request: OrcadMigrationSnapshotChunkRequest -): OrcadMigrationSnapshotChunkResult { - const record = requiredRecord(value, 'orcad_migration_snapshot_chunk_result_invalid') - const acknowledgedOffset = - request.offset + decodeOrcadMigrationSnapshotChunk(request.bytesBase64).length - if ( - record.migrationId !== request.migrationId || - record.manifestSha256 !== request.manifestSha256 || - record.ref !== request.ref || - !Number.isSafeInteger(record.acknowledgedOffset) || - Number(record.acknowledgedOffset) !== acknowledgedOffset - ) { - throw new Error('orcad_migration_snapshot_chunk_result_invalid') - } - return { - migrationId: request.migrationId, - manifestSha256: request.manifestSha256, - ref: request.ref, - acknowledgedOffset - } -} - -export function parseOrcadMigrationTerminalScrollbackSnapshots( - value: unknown -): OrcadMigrationTerminalScrollbackSnapshot[] { - const snapshots = boundedArray( - value, - parseSnapshot, - 'scrollback_snapshots', - MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS - ) - assertUnique(snapshots, (entry) => entry.ref, 'scrollback_snapshot_ref') - assertUnique(snapshots, (entry) => `${entry.tabId}\0${entry.leafId}`, 'scrollback_snapshot_leaf') - if ( - snapshots.reduce((total, entry) => total + entry.byteLength, 0) > - MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES - ) { - throw new Error('orcad_migration_dormant_scrollback_snapshots_too_large') - } - return snapshots -} - -export function assertOrcadMigrationScrollbackReferences( - session: WorkspaceSessionState | undefined, - snapshots: readonly OrcadMigrationTerminalScrollbackSnapshot[] -): void { - const expected = new Map(snapshots.map((entry) => [`${entry.tabId}\0${entry.leafId}`, entry])) - let referenceCount = 0 - for (const [tabId, layout] of Object.entries(session?.terminalLayoutsByTabId ?? {})) { - for (const [leafId, ref] of Object.entries(layout.scrollbackRefsByLeafId ?? {})) { - const descriptor = expected.get(`${tabId}\0${leafId}`) - if (!descriptor || descriptor.ref !== ref) { - throw new Error('orcad_migration_dormant_scrollback_reference_invalid') - } - referenceCount += 1 - } - } - if (referenceCount !== snapshots.length) { - throw new Error('orcad_migration_dormant_scrollback_reference_invalid') - } -} - -export function decodeOrcadMigrationSnapshotChunk(bytesBase64: string): Buffer { - const bytes = Buffer.from(bytesBase64, 'base64') - if ( - bytes.length === 0 || - bytes.length > ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES || - bytes.toString('base64') !== bytesBase64 - ) { - throw new Error('orcad_migration_snapshot_chunk_invalid') - } - return bytes -} - -function parseSnapshot(value: unknown): OrcadMigrationTerminalScrollbackSnapshot { - const record = requiredRecord(value, 'orcad_migration_dormant_scrollback_snapshot_invalid') - const ref = requiredString(record.ref, 'orcad_migration_dormant_scrollback_ref_invalid') - const sha256 = requiredString(record.sha256, 'orcad_migration_dormant_scrollback_digest_invalid') - if (!SnapshotRef.safeParse(ref).success || !Digest.safeParse(sha256).success) { - throw new Error('orcad_migration_dormant_scrollback_identity_invalid') - } - if ( - !Number.isSafeInteger(record.byteLength) || - Number(record.byteLength) < 1 || - Number(record.byteLength) > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT - ) { - throw new Error('orcad_migration_dormant_scrollback_size_invalid') - } - return { - tabId: requiredString(record.tabId, 'orcad_migration_dormant_scrollback_tab_invalid'), - leafId: requiredString(record.leafId, 'orcad_migration_dormant_scrollback_leaf_invalid'), - ref, - sha256, - byteLength: Number(record.byteLength) - } -} diff --git a/src/shared/orcad-migration-staged-catalog-validation.test.ts b/src/shared/orcad-migration-staged-catalog-validation.test.ts deleted file mode 100644 index 9219d47a3c2b..000000000000 --- a/src/shared/orcad-migration-staged-catalog-validation.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { normalizeOrcadMigrationStagedCatalogs } from './orcad-migration-staged-catalog-validation' -import { ORCAD_MIGRATION_MANIFEST_VERSION } from './orcad-migration-manifest-validation' - -function staged(migrationId: string) { - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - stagedAt: '2026-08-30T12:00:00.000Z', - manifest: { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId, - createdAt: '2026-08-30T12:00:00.000Z', - source: { sshTargetId: 'ssh-prod', sshTargetGeneration: 1, targetLabel: 'Production' }, - payload: { repositories: [], projectGroups: [], folderWorkspaces: [] }, - manifestSha256: 'a'.repeat(64) - } - } -} - -describe('orcad staged catalog normalization', () => { - it('recovers without Node 20-only array methods', () => { - const prototype: object = Array.prototype - const descriptor = Object.getOwnPropertyDescriptor(prototype, 'toReversed') - try { - Object.defineProperty(prototype, 'toReversed', { - configurable: true, - value: undefined, - writable: true - }) - expect(normalizeOrcadMigrationStagedCatalogs([staged('migration-1')])).toEqual([ - expect.objectContaining({ - manifest: expect.objectContaining({ migrationId: 'migration-1' }) - }) - ]) - } finally { - if (descriptor) { - Object.defineProperty(prototype, 'toReversed', descriptor) - } else { - Reflect.deleteProperty(prototype, 'toReversed') - } - } - }) -}) diff --git a/src/shared/orcad-migration-staged-catalog-validation.ts b/src/shared/orcad-migration-staged-catalog-validation.ts deleted file mode 100644 index f32dafec231c..000000000000 --- a/src/shared/orcad-migration-staged-catalog-validation.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { OrcadMigrationStagedCatalog } from './orcad-migration-manifest' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest -} from './orcad-migration-manifest-validation' - -export const MAX_ORCAD_MIGRATION_STAGED_CATALOGS = 4 - -export function normalizeOrcadMigrationStagedCatalogs( - value: unknown -): OrcadMigrationStagedCatalog[] { - if (!Array.isArray(value)) { - return [] - } - const staged: OrcadMigrationStagedCatalog[] = [] - const seen = new Set() - for (let index = value.length - 1; index >= 0; index--) { - try { - const entry = parseStagedCatalog(value[index]) - if (!seen.has(entry.manifest.migrationId)) { - seen.add(entry.manifest.migrationId) - staged.push(entry) - if (staged.length === MAX_ORCAD_MIGRATION_STAGED_CATALOGS) { - break - } - } - } catch { - // Invalid staging cannot be trusted as fencing evidence, so it is dropped fail-closed. - } - } - // Keep the migration journal runnable in the documented Node 18 rollback slot. - return staged.reduceRight((reversed, entry) => { - reversed.push(entry) - return reversed - }, []) -} - -function parseStagedCatalog(value: unknown): OrcadMigrationStagedCatalog { - if (!isRecord(value) || value.version !== ORCAD_MIGRATION_MANIFEST_VERSION) { - throw new Error('orcad_migration_staged_catalog_invalid') - } - const stagedAt = typeof value.stagedAt === 'string' ? value.stagedAt : '' - if (!Number.isFinite(Date.parse(stagedAt))) { - throw new Error('orcad_migration_staged_at_invalid') - } - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - manifest: parseOrcadMigrationManifest(value.manifest), - stagedAt - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} From 2b13568029131afb0782d23b33b7ea847ea87d69 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:04 -0700 Subject: [PATCH 18/26] Revert "feat(ssh): wire SshConnection through the work and transport close ledgers (#16741 T2 P2) (#24401)" This reverts commit 4e8edc887242366d97fc42705e1fab3c87f97aa1. --- src/main/ssh/ssh-channel-open.ts | 204 ----- .../ssh/ssh-connection-channel-error.test.ts | 63 -- .../ssh-connection-channel-lifetime.test.ts | 39 +- .../ssh/ssh-connection-channel-lifetime.ts | 30 +- .../ssh/ssh-connection-channel-open.test.ts | 12 +- src/main/ssh/ssh-connection-close-drain.ts | 34 - ...ssh-connection-destination-capture.test.ts | 123 --- .../ssh/ssh-connection-destination.test.ts | 57 -- src/main/ssh/ssh-connection-destination.ts | 74 -- .../ssh/ssh-connection-direct-startup.test.ts | 8 +- .../ssh-connection-disconnect-drain.test.ts | 243 ------ .../ssh/ssh-connection-file-transfer.test.ts | 7 +- src/main/ssh/ssh-connection-file-transfers.ts | 219 ------ ...h-connection-host-key-store-wiring.test.ts | 5 +- .../ssh-connection-reconnect-ladder.test.ts | 35 - .../ssh-connection-system-work-drain.test.ts | 100 --- src/main/ssh/ssh-connection-test-fixtures.ts | 9 +- .../ssh-connection-transport-closure.test.ts | 179 ----- .../ssh/ssh-connection-transport-closure.ts | 33 - .../ssh/ssh-connection-work-drain.test.ts | 244 ------ src/main/ssh/ssh-connection.ts | 718 +++++++++--------- .../ssh/ssh-forward-channel-lifetime.test.ts | 102 --- src/main/ssh/ssh-forward-channel-lifetime.ts | 78 -- .../ssh-multi-factor-authentication.test.ts | 31 +- src/main/ssh/ssh-relay-exec-command.ts | 2 +- src/main/ssh/ssh-relay-exec-input.test.ts | 57 -- .../ssh-system-transport.integration.test.ts | 6 +- .../ssh/ssh-upload-session-lifetime.test.ts | 193 ----- src/main/ssh/ssh-upload-session-lifetime.ts | 33 - src/main/ssh/system-ssh-command.ts | 4 - 30 files changed, 421 insertions(+), 2521 deletions(-) delete mode 100644 src/main/ssh/ssh-channel-open.ts delete mode 100644 src/main/ssh/ssh-connection-channel-error.test.ts delete mode 100644 src/main/ssh/ssh-connection-close-drain.ts delete mode 100644 src/main/ssh/ssh-connection-destination-capture.test.ts delete mode 100644 src/main/ssh/ssh-connection-destination.test.ts delete mode 100644 src/main/ssh/ssh-connection-destination.ts delete mode 100644 src/main/ssh/ssh-connection-disconnect-drain.test.ts delete mode 100644 src/main/ssh/ssh-connection-file-transfers.ts delete mode 100644 src/main/ssh/ssh-connection-system-work-drain.test.ts delete mode 100644 src/main/ssh/ssh-connection-transport-closure.test.ts delete mode 100644 src/main/ssh/ssh-connection-transport-closure.ts delete mode 100644 src/main/ssh/ssh-connection-work-drain.test.ts delete mode 100644 src/main/ssh/ssh-forward-channel-lifetime.test.ts delete mode 100644 src/main/ssh/ssh-forward-channel-lifetime.ts delete mode 100644 src/main/ssh/ssh-relay-exec-input.test.ts delete mode 100644 src/main/ssh/ssh-upload-session-lifetime.test.ts delete mode 100644 src/main/ssh/ssh-upload-session-lifetime.ts diff --git a/src/main/ssh/ssh-channel-open.ts b/src/main/ssh/ssh-channel-open.ts deleted file mode 100644 index e46d2c28f620..000000000000 --- a/src/main/ssh/ssh-channel-open.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { trackSshConnectionChannelLifetime } from './ssh-connection-channel-lifetime' -import { CONNECT_TIMEOUT_MS, createSshOperationAbortError } from './ssh-connection-utils' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' -import { isSshSessionLimitError } from './ssh-session-limit-error' - -type SshChannelOpenWork = ReturnType - -// Upper bound on waiting for an aborted channel's open/close to settle before rejecting anyway. -const ABORTED_CHANNEL_CLOSE_GRACE_MS = 5_000 - -// Why: MaxSessions servers can transiently refuse a channel open; a refused open never ran the command, so retry is safe. -const SESSION_LIMIT_OPEN_RETRIES = 4 -const SESSION_LIMIT_OPEN_RETRY_DELAY_MS = 150 - -export async function openSshSessionChannelWithRetry( - open: () => Promise, - signal?: AbortSignal -): Promise { - let lastError: unknown - for (let attempt = 0; attempt < SESSION_LIMIT_OPEN_RETRIES; attempt++) { - if (attempt > 0) { - // Why: an abort must release the backoff immediately, not after it. - if (!signal?.aborted) { - await new Promise((resolve) => { - const onDelayDone = (): void => { - clearTimeout(delayTimer) - signal?.removeEventListener('abort', onDelayDone) - resolve() - } - const delayTimer = setTimeout(onDelayDone, SESSION_LIMIT_OPEN_RETRY_DELAY_MS) - signal?.addEventListener('abort', onDelayDone, { once: true }) - }) - } - if (signal?.aborted) { - throw createSshOperationAbortError() - } - } - try { - return await open() - } catch (err) { - if (!isSshSessionLimitError(err)) { - throw err - } - lastError = err - } - } - throw lastError -} - -/** - * Waits for an ssh2 channel open, bounded by CONNECT_TIMEOUT_MS and an abort grace. The channel, - * late ones included, stays on `work` until it physically closes. - */ -export function waitForSshChannelOpen( - work: SshChannelOpenWork, - timeoutMessage: string, - register: (callback: (error: Error | undefined, value: T) => void) => void, - cleanupLateValue?: (value: T) => void, - signal?: AbortSignal, - trackRemoteCommandTermination = false, - onUnhandledError?: (error: Error) => void -): Promise { - return new Promise((resolve, reject) => { - type ChannelOpenTerminationError = Error & { sshChannelCloseConfirmed: boolean } - let settled = false - let unconfirmedOpenError: ChannelOpenTerminationError | null = null - const markOpenUnconfirmed = (error: Error): Error => { - work.markUnverifiable(error) - if (!trackRemoteCommandTermination) { - return error - } - unconfirmedOpenError = Object.assign(error, { sshChannelCloseConfirmed: false }) - return unconfirmedOpenError - } - // Why: an in-flight open holds a MaxSessions slot; reject the caller now, then settle from the open callback once the late channel closes. - let abortRequested = false - let abortDeadlineTimer: NodeJS.Timeout | undefined - const cleanup = (): void => { - clearTimeout(timer) - clearTimeout(abortDeadlineTimer) - signal?.removeEventListener('abort', onAbort) - } - const onAbort = (): void => { - abortRequested = true - // Why: a hung socket may never invoke the open callback; bound the aborted caller's wait instead of pinning it for CONNECT_TIMEOUT_MS. - abortDeadlineTimer = setTimeout(() => { - settled = true - cleanup() - reject(markOpenUnconfirmed(createSshOperationAbortError())) - }, ABORTED_CHANNEL_CLOSE_GRACE_MS) - } - const timer = setTimeout(() => { - settled = true - cleanup() - reject( - markOpenUnconfirmed( - abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage) - ) - ) - }, CONNECT_TIMEOUT_MS) - const discardLateValue = (value: T, onClose?: () => void): void => { - const emitter = value as Partial & { - resume?: () => void - stderr?: Partial & { resume?: () => void } - } - const swallowLateError = (): void => {} - emitter.on?.('error', swallowLateError) - emitter.stderr?.on?.('error', swallowLateError) - if (onClose) { - emitter.once?.('close', onClose) - } - // Why: ssh2 withholds CHANNEL_CLOSE while discarded exec streams remain unread, and teardown errors have no other owner. - emitter.resume?.() - emitter.stderr?.resume?.() - try { - cleanupLateValue?.(value) - } catch { - /* best effort */ - } - } - const rejectAfterClose = (value: T): void => { - const abortError = markOpenUnconfirmed(createSshOperationAbortError()) - const emitter = value as Partial & { - resume?: () => void - stderr?: { resume?: () => void } - } - let finished = false - const done = (): void => { - if (finished) { - return - } - finished = true - clearTimeout(closeGraceTimer) - emitter.removeListener?.('close', confirmAndDone) - reject(abortError) - } - const confirmAndDone = (): void => { - if (unconfirmedOpenError === abortError) { - unconfirmedOpenError.sshChannelCloseConfirmed = true - } - done() - } - // Why: bounded so a remote that never confirms the close can't hang the aborted operation forever. - const closeGraceTimer = setTimeout(done, ABORTED_CHANNEL_CLOSE_GRACE_MS) - if (typeof emitter.once === 'function') { - emitter.once('close', confirmAndDone) - } - // Why: ssh2 withholds 'close' until the channel's streams are drained; nobody else will read this discarded channel. - discardLateValue(value) - if (typeof emitter.once !== 'function') { - done() - } - } - const finish = (error: Error | undefined, value?: T): void => { - // Late channels stay tracked until physical close, after the caller has given up. - if (!error && value !== undefined) { - trackSshConnectionChannelLifetime(work, value, onUnhandledError) - } else { - work.close(error) - } - if (settled) { - // Why: ssh2 can invoke the open callback after our timeout rejected; close that late channel so it isn't left open with no owner. - if (!error && value !== undefined) { - discardLateValue(value, () => { - if (unconfirmedOpenError) { - unconfirmedOpenError.sshChannelCloseConfirmed = true - } - }) - } - return - } - settled = true - cleanup() - if (abortRequested) { - if (!error && value !== undefined) { - rejectAfterClose(value) - } else { - reject(createSshOperationAbortError()) - } - return - } - if (error) { - reject(error) - return - } - resolve(value as T) - } - if (signal?.aborted) { - // No open is in flight yet, so failing fast leaks nothing. - cleanup() - work.close() - reject(createSshOperationAbortError()) - return - } - signal?.addEventListener('abort', onAbort, { once: true }) - - try { - // Why: higher-level channel timers start only after ssh2's open callback; a stale SSH socket can otherwise keep exec/sftp stuck. - register(finish) - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))) - } - }) -} diff --git a/src/main/ssh/ssh-connection-channel-error.test.ts b/src/main/ssh/ssh-connection-channel-error.test.ts deleted file mode 100644 index 8ecede182139..000000000000 --- a/src/main/ssh/ssh-connection-channel-error.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { createCallbacks, createTarget } from './ssh-connection-test-fixtures' -import { resetSshConnectionMocks, ssh2Mock } from './ssh-connection-test-harness' -import * as sshClientMock from './__tests__/ssh-connection-test-client' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) - -beforeEach(resetSshConnectionMocks) -afterEach(() => { - vi.restoreAllMocks() -}) - -it('logs an exec channel error nothing else handles, naming the target and channel kind', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - ssh2Mock.execBehavior = 'pending' - const opening = conn.exec('true') - await vi.waitFor(() => expect(sshClientMock.pendingExecCallback).not.toBeNull()) - const channel = Object.assign(new EventEmitter(), { close: vi.fn() }) - sshClientMock.pendingExecCallback?.(undefined, channel) - await opening - - expect(() => channel.emit('error', new Error('channel reset'))).not.toThrow() - expect(warn).toHaveBeenCalledWith( - '[ssh] Unhandled exec channel error for Test Server: channel reset' - ) - await conn.disconnect() -}) - -it('does not log a forwarded channel error its owner handles', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - const channel = new EventEmitter() - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback(undefined, channel as never) - return client - }) - const owner = vi.fn() - conn.forwardStreamLocal(client, '/owned.sock', () => channel.on('error', owner)) - - channel.emit('error', new Error('handled')) - expect(owner).toHaveBeenCalledOnce() - expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('Unhandled')) - await conn.disconnect() -}) diff --git a/src/main/ssh/ssh-connection-channel-lifetime.test.ts b/src/main/ssh/ssh-connection-channel-lifetime.test.ts index 82371f36880f..56b5a84f92a9 100644 --- a/src/main/ssh/ssh-connection-channel-lifetime.test.ts +++ b/src/main/ssh/ssh-connection-channel-lifetime.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' +import { expect, it } from 'vitest' import { trackSshConnectionChannelLifetime } from './ssh-connection-channel-lifetime' import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' @@ -63,40 +63,3 @@ it('runs the injected admission check before admitting work', () => { }) expect(() => ledger.beginChannelOpen()).toThrow('connection disposed') }) - -it('reports a channel error that nothing else handles instead of hiding it', () => { - const report = vi.fn() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime( - new SshConnectionWorkLedger().beginChannelOpen(), - channel, - report - ) - const failure = new Error('channel reset') - expect(() => channel.emit('error', failure)).not.toThrow() - expect(report).toHaveBeenCalledWith(failure) -}) - -it('leaves a channel error to the owner that handles it', () => { - const report = vi.fn() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime( - new SshConnectionWorkLedger().beginChannelOpen(), - channel, - report - ) - const owner = vi.fn() - channel.on('error', owner) - channel.emit('error', new Error('handled')) - expect(owner).toHaveBeenCalledOnce() - expect(report).not.toHaveBeenCalled() -}) - -it('logs an unhandled channel error with the [ssh] prefix when no reporter is given', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const channel = new EventEmitter() - trackSshConnectionChannelLifetime(new SshConnectionWorkLedger().beginChannelOpen(), channel) - channel.emit('error', new Error('orphaned')) - expect(warn).toHaveBeenCalledWith('[ssh] Unhandled SSH channel error: orphaned') - warn.mockRestore() -}) diff --git a/src/main/ssh/ssh-connection-channel-lifetime.ts b/src/main/ssh/ssh-connection-channel-lifetime.ts index b09a7d288c9d..c6ad86100bcf 100644 --- a/src/main/ssh/ssh-connection-channel-lifetime.ts +++ b/src/main/ssh/ssh-connection-channel-lifetime.ts @@ -21,30 +21,14 @@ function isTrackableChannel(value: unknown): value is TrackableChannel { ) } -export type SshChannelErrorReporter = (error: Error) => void - -const reportOrphanChannelError: SshChannelErrorReporter = (error) => { - console.warn(`[ssh] Unhandled SSH channel error: ${error.message}`) -} - -// The tracker's own listener must not be what keeps an error from surfacing. -function hasOnlyTrackerErrorListener(channel: TrackableChannel): boolean { - return ( - !('listenerCount' in channel) || - typeof channel.listenerCount !== 'function' || - channel.listenerCount('error') <= 1 - ) -} - export function openTrackedSshSocket( ledger: SshConnectionWorkLedger, - open: () => T, - onUnhandledError?: SshChannelErrorReporter + open: () => T ): T { const work = ledger.beginChannelOpen() try { const socket = open() - trackSshConnectionChannelLifetime(work, socket, onUnhandledError) + trackSshConnectionChannelLifetime(work, socket) return socket } catch (error) { work.markUnverifiable(error instanceof Error ? error : new Error(String(error))) @@ -55,8 +39,7 @@ export function openTrackedSshSocket( /** Start tracking before the open callback hands the channel to another owner. */ export function trackSshConnectionChannelLifetime( work: ReturnType, - value: unknown, - onUnhandledError: SshChannelErrorReporter = reportOrphanChannelError + value: unknown ): void { if (!isTrackableChannel(value)) { work.markUnverifiable(new Error('ssh_connection_channel_lifetime_unverifiable')) @@ -68,12 +51,7 @@ export function trackSshConnectionChannelLifetime( work.close() return } - const onError = (error: Error) => { - work.markUnverifiable(error) - if (hasOnlyTrackerErrorListener(channel)) { - onUnhandledError(error) - } - } + const onError = (error: Error) => work.markUnverifiable(error) channel.on('error', onError) channel.once('close', () => { channel.removeListener('error', onError) diff --git a/src/main/ssh/ssh-connection-channel-open.test.ts b/src/main/ssh/ssh-connection-channel-open.test.ts index 58d4a5633b23..f0f6454af648 100644 --- a/src/main/ssh/ssh-connection-channel-open.test.ts +++ b/src/main/ssh/ssh-connection-channel-open.test.ts @@ -286,7 +286,7 @@ describe('SshConnection', () => { await expect(outcomePromise).resolves.toBe('AbortError') }) - it('removes the abort-grace waiter while retaining physical-close tracking', async () => { + it('removes the late-channel close listener when abort grace expires', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) await conn.connect() vi.useFakeTimers() @@ -302,14 +302,12 @@ describe('SshConnection', () => { await vi.advanceTimersByTimeAsync(0) controller.abort() pendingSftpCallback?.(undefined, lateSftp) - expect(lateSftp.listenerCount('close')).toBe(2) + expect(lateSftp.listenerCount('close')).toBe(1) expect(() => lateSftp.emit('error', new Error('late SFTP teardown'))).not.toThrow() await vi.advanceTimersByTimeAsync(5_000) await expect(outcomePromise).resolves.toBe('AbortError') - expect(lateSftp.listenerCount('close')).toBe(1) - lateSftp.emit('close') expect(lateSftp.listenerCount('close')).toBe(0) } finally { vi.useRealTimers() @@ -378,7 +376,7 @@ describe('SshConnection', () => { expect(lateSftp.end).toHaveBeenCalledTimes(1) }) - it('retains SFTP physical-close tracking after the bounded observation expires', async () => { + it('removes the late SFTP close listener when the bounded grace expires', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) await conn.connect() ssh2Mock.sftpBehavior = 'pending' @@ -395,13 +393,11 @@ describe('SshConnection', () => { await Promise.resolve() controller.abort() pendingSftpCallback?.(undefined, lateSftp) - expect(lateSftp.listenerCount('close')).toBe(2) + expect(lateSftp.listenerCount('close')).toBe(1) await vi.advanceTimersByTimeAsync(5_000) await expect(outcomePromise).resolves.toBe('AbortError') - expect(lateSftp.listenerCount('close')).toBe(1) - lateSftp.emit('close') expect(lateSftp.listenerCount('close')).toBe(0) expect(lateSftp.end).toHaveBeenCalledTimes(1) } finally { diff --git a/src/main/ssh/ssh-connection-close-drain.ts b/src/main/ssh/ssh-connection-close-drain.ts deleted file mode 100644 index e905a62ceaab..000000000000 --- a/src/main/ssh/ssh-connection-close-drain.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { EventEmitter } from 'node:events' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' -import type { SshTransportCloseLedger } from './ssh-transport-close-ledger' -import { observeSshTransportClose } from './ssh-transport-close-observation' - -type WorkFence = ReturnType - -/** - * Disconnects, then waits for the live transport resources, every client ever allocated and all - * fenced work to close. Listeners go on before close starts so a fast 'close' is not missed. - */ -export async function disconnectAndAwaitSshTransportClose(options: { - liveResources: readonly EventEmitter[] - disconnect: () => Promise - transportCloseLedger: SshTransportCloseLedger - fence: WorkFence - signal: AbortSignal -}): Promise { - const observation = observeSshTransportClose(options.liveResources) - const waiting = new AbortController() - const waitSignal = AbortSignal.any([options.signal, waiting.signal]) - try { - await options.disconnect() - await Promise.all([ - observation.wait(waitSignal), - options.transportCloseLedger.drain(waitSignal), - options.fence.drain(waitSignal) - ]) - options.fence.assertDrained() - } finally { - waiting.abort() - observation.dispose() - } -} diff --git a/src/main/ssh/ssh-connection-destination-capture.test.ts b/src/main/ssh/ssh-connection-destination-capture.test.ts deleted file mode 100644 index 6f0ce04a8087..000000000000 --- a/src/main/ssh/ssh-connection-destination-capture.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { createHash } from 'node:crypto' -import { beforeEach, expect, it, vi } from 'vitest' -import { - clientInstances, - nextSshClientCreation, - resetSshConnectionMocks, - resolveWithSshGMock, - ssh2Mock, - VALID_ED25519_HOST_KEY -} from './ssh-connection-test-harness' -import { createCallbacks, createTarget, createResolvedConfig } from './ssh-connection-test-fixtures' -import { SshConnection } from './ssh-connection' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) - -beforeEach(() => { - resetSshConnectionMocks() -}) - -it('captures only the successful endpoint and accepted key, not the saved alias', async () => { - resolveWithSshGMock.mockResolvedValue( - createResolvedConfig({ - hostname: 'resolved.example', - port: 2222, - user: 'resolved-user', - proxyUseFdpass: false - }) - ) - const conn = new SshConnection(createTarget({ configHost: 'alias' }), createCallbacks()) - expect(conn.getExecutionDestination()).toBeUndefined() - try { - await conn.connect() - const destination = conn.getExecutionDestination() - expect(destination).toEqual({ - version: 1, - transport: 'ssh2', - host: 'resolved.example', - port: 2222, - username: 'resolved-user', - hostKeyFingerprint: `SHA256:${createHash('sha256').update(VALID_ED25519_HOST_KEY).digest('base64').replace(/=+$/, '')}`, - proxyRouteDigest: createHash('sha256').update('null').digest('hex') - }) - expect(Object.isFrozen(destination)).toBe(true) - expect(conn.getExecutionDestination()).toBe(destination) - } finally { - await conn.disconnect() - } - expect(conn.getExecutionDestination()).toBeUndefined() -}) - -it('does not publish an accepted key until authentication succeeds', async () => { - ssh2Mock.connectBehavior = 'pending' - const conn = new SshConnection(createTarget(), createCallbacks()) - const created = nextSshClientCreation() - const connecting = conn.connect() - await created - expect(conn.getExecutionDestination()).toBeUndefined() - const rejected = expect(connecting).rejects.toThrow() - await conn.disconnect() - await rejected - clientInstances[0].emit('ready') - expect(conn.getExecutionDestination()).toBeUndefined() -}) - -it('invalidates identity immediately on transport loss', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - try { - await conn.connect() - expect(conn.getExecutionDestination()).toBeDefined() - clientInstances[0].emit('end') - expect(conn.getExecutionDestination()).toBeUndefined() - } finally { - await conn.disconnect() - } -}) - -it('does not publish an accepted key from failed authentication', async () => { - ssh2Mock.connectBehavior = 'error' - ssh2Mock.connectErrorMessage = 'Authentication failed' - const conn = new SshConnection(createTarget(), createCallbacks()) - try { - await expect(conn.connect()).rejects.toThrow() - expect(ssh2Mock.lastHostKeyAccepted).toBe(true) - expect(conn.getExecutionDestination()).toBeUndefined() - } finally { - await conn.disconnect() - } -}) - -it('never publishes identity from a rejected host key', async () => { - ssh2Mock.presentedHostKey = Buffer.from('invalid host key') - const conn = new SshConnection(createTarget(), createCallbacks()) - try { - await expect(conn.connect()).rejects.toThrow() - expect(conn.getExecutionDestination()).toBeUndefined() - } finally { - await conn.disconnect() - } -}) - -it('does not treat a system SSH probe as command-channel destination evidence', async () => { - const conn = new SshConnection(createTarget({ configHost: 'alias' }), createCallbacks()) - resolveWithSshGMock.mockResolvedValue(createResolvedConfig({ proxyUseFdpass: true })) - try { - await conn.connect() - expect(conn.usesSystemSshTransport()).toBe(true) - expect(conn.getExecutionDestination()).toBeUndefined() - } finally { - await conn.disconnect() - } -}) diff --git a/src/main/ssh/ssh-connection-destination.test.ts b/src/main/ssh/ssh-connection-destination.test.ts deleted file mode 100644 index 7240113e108b..000000000000 --- a/src/main/ssh/ssh-connection-destination.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseSshConnectionDestination } from './ssh-connection-destination' - -const destination = { - version: 1, - transport: 'ssh2', - host: 'resolved.example', - port: 2222, - username: 'owner', - hostKeyFingerprint: `SHA256:${Buffer.alloc(32, 255).toString('base64').replace(/=+$/, '')}`, - proxyRouteDigest: 'a'.repeat(64) -} - -describe('parseSshConnectionDestination', () => { - it('makes an immutable canonical snapshot without normalizing identity', () => { - const input = { ...destination, host: 'MixedCase.example', ignored: true } - const saved = parseSshConnectionDestination(input) - input.host = 'another.example' - expect(saved).toEqual({ ...destination, host: 'MixedCase.example' }) - expect(Object.isFrozen(saved)).toBe(true) - expect(parseSshConnectionDestination(JSON.parse(JSON.stringify(saved)))).toEqual(saved) - }) - - it.each([ - { version: 2 }, - { transport: 'system-ssh' }, - { host: '' }, - { host: 'host\nother' }, - { host: 'h'.repeat(8193) }, - { username: '' }, - { username: 'user\0name' }, - { username: 3 }, - { port: 0 }, - { port: 65536 }, - { port: 22.5 }, - { port: '22' }, - { port: Number.NaN }, - { hostKeyFingerprint: 'SHA256:short' }, - { hostKeyFingerprint: `SHA256:${'A'.repeat(43)}=` }, - { hostKeyFingerprint: `SHA256:${'A'.repeat(42)}B` }, - { proxyRouteDigest: 'A'.repeat(64) }, - { proxyRouteDigest: 'a'.repeat(63) }, - { proxyRouteDigest: undefined } - ])('refuses malformed binding %j', (change) => { - expect(() => parseSshConnectionDestination({ ...destination, ...change })).toThrow( - 'ssh_connection_destination_invalid' - ) - }) - - it.each([null, undefined, [], 'host', 1])('refuses nonrecords %j', (value) => { - expect(() => parseSshConnectionDestination(value)).toThrow('ssh_connection_destination_invalid') - }) - - it.each([1, 65535])('accepts port boundary %i', (port) => { - expect(parseSshConnectionDestination({ ...destination, port }).port).toBe(port) - }) -}) diff --git a/src/main/ssh/ssh-connection-destination.ts b/src/main/ssh/ssh-connection-destination.ts deleted file mode 100644 index 49bbbe8545a0..000000000000 --- a/src/main/ssh/ssh-connection-destination.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { createHash } from 'node:crypto' - -/** The execution host a successful ssh2 handshake proved: endpoint, accepted key and proxy route. */ -export type SshConnectionDestination = Readonly<{ - version: 1 - transport: 'ssh2' - host: string - port: number - username: string - hostKeyFingerprint: string - proxyRouteDigest: string -}> - -/** Digest of the resolved proxy route; a changed ProxyCommand/ProxyJump names a different path. */ -export function sshProxyRouteDigest(effectiveProxy: unknown): string { - return createHash('sha256') - .update(JSON.stringify(effectiveProxy ?? null)) - .digest('hex') -} - -function isValidText(text: unknown): text is string { - return ( - typeof text === 'string' && - text.length > 0 && - text.length <= 8192 && - !Array.from(text).some( - (character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127 - ) - ) -} - -function isCanonicalSha256Fingerprint(value: unknown): value is string { - if (typeof value !== 'string' || !/^SHA256:[A-Za-z0-9+/]{43}$/.test(value)) { - return false - } - const encoded = value.slice(7) - // Why: a non-canonical trailing digit decodes to the same bytes but names a different string. - return ( - Buffer.from(encoded, 'base64').toString('base64url') === - encoded.replace(/\+/g, '-').replace(/\//g, '_') - ) -} - -export function parseSshConnectionDestination(value: unknown): SshConnectionDestination { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error('ssh_connection_destination_invalid') - } - const record: Record = { ...value } - const { host, port, username, hostKeyFingerprint, proxyRouteDigest } = record - if ( - record.version !== 1 || - record.transport !== 'ssh2' || - !isValidText(host) || - typeof port !== 'number' || - !Number.isSafeInteger(port) || - port < 1 || - port > 65535 || - !isValidText(username) || - !isCanonicalSha256Fingerprint(hostKeyFingerprint) || - typeof proxyRouteDigest !== 'string' || - !/^[a-f0-9]{64}$/.test(proxyRouteDigest) - ) { - throw new Error('ssh_connection_destination_invalid') - } - return Object.freeze({ - version: 1, - transport: 'ssh2', - host, - port, - username, - hostKeyFingerprint, - proxyRouteDigest - }) -} diff --git a/src/main/ssh/ssh-connection-direct-startup.test.ts b/src/main/ssh/ssh-connection-direct-startup.test.ts index 09bb39e653b1..a2344930eeae 100644 --- a/src/main/ssh/ssh-connection-direct-startup.test.ts +++ b/src/main/ssh/ssh-connection-direct-startup.test.ts @@ -56,16 +56,12 @@ describe('SshConnection', () => { await connect expect(channel.close).toHaveBeenCalled() expect(channel.listenerCount('data')).toBe(0) - // Lifetime observers remain until physical close, even after the probe timed out. - expect(channel.listenerCount('error')).toBe(2) - expect(channel.listenerCount('close')).toBe(2) + expect(channel.listenerCount('error')).toBe(1) + expect(channel.listenerCount('close')).toBe(1) expect(channel.stderr.listenerCount('data')).toBe(0) expect( (conn as unknown as { systemCommandChannels: Set }).systemCommandChannels.size ).toBe(0) - channel.emit('close', 0) - expect(channel.listenerCount('close')).toBe(0) - expect(channel.listenerCount('error')).toBe(1) } finally { vi.useRealTimers() } diff --git a/src/main/ssh/ssh-connection-disconnect-drain.test.ts b/src/main/ssh/ssh-connection-disconnect-drain.test.ts deleted file mode 100644 index b5244ff5f327..000000000000 --- a/src/main/ssh/ssh-connection-disconnect-drain.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { createCallbacks, createResolvedConfig, createTarget } from './ssh-connection-test-fixtures' -import { clientInstances, resetSshConnectionMocks, ssh2Mock } from './ssh-connection-test-harness' -import { resolveWithSshG } from './ssh-config-parser' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) -beforeEach(resetSshConnectionMocks) -afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() -}) - -async function fixture(conn = new SshConnection(createTarget(), createCallbacks())) { - await conn.connect() - const client = conn.getClient()! - // The shared SSH mock omits once/removeListener; retain its lifecycle handlers alongside real events. - const observed = new EventEmitter() - const emit = client.emit.bind(client) - Object.assign(client, { - once: observed.once.bind(observed), - removeListener: observed.removeListener.bind(observed), - listenerCount: observed.listenerCount.bind(observed), - emit: (event: string, ...args: unknown[]) => { - emit(event, ...args) - return observed.emit(event, ...args) - } - }) - const end = vi.spyOn(client, 'end').mockImplementation(() => client) - const run = (signal = new AbortController().signal) => conn.disconnectAndDrain(signal) - const forward = () => { - const channel = new EventEmitter() - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback(undefined, channel as never) - return client - }) - conn.forwardStreamLocal(client, '/owned.sock', vi.fn()) - return channel - } - return { conn, client, end, run, forward } -} - -it('waits for failed startup sockets even after another attempt connects successfully', async () => { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - ssh2Mock.connectSequence = [new Error('startup refused')] - ssh2Mock.destroyErrorMessage = 'socket still closing' - await expect(conn.connect()).rejects.toThrow('startup refused') - const failed = clientInstances[0]! - ssh2Mock.destroyErrorMessage = '' - const f = await fixture(conn) - const done = vi.fn() - const draining = f.run().then(done) - f.client.emit('close') - // Let all resolved drain promises settle; the failed socket remains open. - await new Promise((resolve) => setImmediate(resolve)) - expect(done).not.toHaveBeenCalled() - failed.emit('close') - await draining - expect(done).toHaveBeenCalledOnce() -}) - -it('drains a settled failed exclusive startup only after its socket physically closes', async () => { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - ssh2Mock.connectSequence = [new Error('startup refused')] - ssh2Mock.destroyErrorMessage = 'socket still closing' - await expect(conn.connect()).rejects.toThrow('startup refused') - const failed = clientInstances[0]! - const done = vi.fn() - const draining = conn.disconnectAndDrain(new AbortController().signal).then(done) - await new Promise((resolve) => setImmediate(resolve)) - expect(done).not.toHaveBeenCalled() - failed.emit('close') - await draining - expect(done).toHaveBeenCalledOnce() - await expect(conn.connect()).rejects.toThrow('ssh_connection_reset_replacement_refused') -}) - -it('remembers failed socket closure across an early ordinary disconnect', async () => { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - ssh2Mock.connectSequence = [new Error('startup refused')] - await expect(conn.connect()).rejects.toThrow('startup refused') - await conn.disconnect() - await conn.disconnectAndDrain(new AbortController().signal) -}) - -it('allows a fenced exclusive connection with no allocations to finish cleanup', async () => { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - await conn.disconnectAndDrain(new AbortController().signal) - await expect(conn.connect()).rejects.toThrow('ssh_connection_reset_replacement_refused') -}) - -it('refuses drain while configuration can still allocate a startup transport', async () => { - const resolved = Promise.withResolvers() - vi.mocked(resolveWithSshG).mockReturnValueOnce(resolved.promise) - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - const connecting = conn.connect().catch((error: unknown) => error) - await expect(conn.disconnectAndDrain(new AbortController().signal)).rejects.toThrow( - 'ssh_connection_close_transport_unproven' - ) - resolved.resolve(null) - expect(await connecting).toBeInstanceOf(Error) - await conn.disconnectAndDrain(new AbortController().signal) - expect(clientInstances).toHaveLength(0) -}) - -it('does not treat logical disconnection as physical client closure', async () => { - const f = await fixture() - const done = vi.fn() - const draining = f.run().then(done) - await Promise.resolve() - expect(f.end).toHaveBeenCalledOnce() - expect(f.conn.getState().status).toBe('disconnected') - expect(done).not.toHaveBeenCalled() - f.client.emit('close') - await draining - expect(done).toHaveBeenCalledOnce() -}) - -it.each(['client', 'channel'] as const)( - 'requires both physical client and channel close when %s closes first', - async (first) => { - const f = await fixture() - const channel = f.forward() - const done = vi.fn() - const draining = f.run().then(done) - ;(first === 'client' ? f.client : channel).emit('close') - await Promise.resolve() - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - ;(first === 'client' ? channel : f.client).emit('close') - await draining - expect(done).toHaveBeenCalledOnce() - } -) - -it('subscribes before disconnect can synchronously close the client', async () => { - const f = await fixture() - f.end.mockImplementation(() => { - f.client.emit('close') - return f.client - }) - await f.run() - expect(f.end).toHaveBeenCalledOnce() -}) - -it('aborts waiting for physical close and removes its listener', async () => { - const f = await fixture() - const before = f.client.listenerCount('close') - const controller = new AbortController() - const draining = f.run(controller.signal) - expect(f.client.listenerCount('close')).toBeGreaterThan(before) - controller.abort() - await expect(draining).rejects.toThrow() - expect(f.client.listenerCount('close')).toBe(before) - expect(f.conn.getClient()).toBeNull() -}) - -it('pre-aborted admission does not close the connection', async () => { - const f = await fixture() - await expect(f.run(AbortSignal.abort())).rejects.toThrow() - expect(f.end).not.toHaveBeenCalled() - await f.conn.disconnect() -}) - -it('aborts unresolved channel drain even after the physical client closes', async () => { - const f = await fixture() - const channel = f.forward() - const controller = new AbortController() - const draining = f.run(controller.signal) - f.client.emit('close') - controller.abort() - await expect(draining).rejects.toThrow() - expect(f.client.listenerCount('close')).toBe(0) - channel.emit('close') -}) - -it('cleans owned overlapping clients but refuses unproven drain', async () => { - const f = await fixture() - const pending = { end: vi.fn(), destroy: vi.fn() } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads SshConnection's own private pending-client set. - const clients = (f.conn as unknown as { pendingSsh2Clients: Set }).pendingSsh2Clients - clients.add(pending) - await expect(f.run()).rejects.toThrow() - expect(f.end).toHaveBeenCalledOnce() - expect(pending.destroy).toHaveBeenCalledOnce() - clients.delete(pending) - await f.conn.disconnect() -}) - -it('waits for the owned proxy close as well as client close', async () => { - const f = await fixture() - const proxy = Object.assign(new EventEmitter(), { kill: vi.fn() }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: installs a stub proxy process into SshConnection's private field. - ;(f.conn as unknown as { proxyProcess: typeof proxy }).proxyProcess = proxy - const done = vi.fn() - const draining = f.run().then(done) - expect(proxy.kill).toHaveBeenCalledOnce() - f.client.emit('close') - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - proxy.emit('close') - await draining -}) - -it('never schedules reconnect when a drain closes its client', async () => { - const f = await fixture() - vi.useFakeTimers() - const draining = f.run() - f.client.emit('close') - await draining - await vi.advanceTimersByTimeAsync(60_000) - expect(f.conn.getClient()).toBeNull() - expect(f.conn.getState().reconnectAttempt).toBe(0) - expect(vi.getTimerCount()).toBe(0) -}) - -it('refuses a disconnected connection without claiming drain proof', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await expect(conn.disconnectAndDrain(new AbortController().signal)).rejects.toThrow() -}) - -it('closes owned system SSH but refuses unproven drain', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) - const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) - await conn.connect() - await expect(conn.disconnectAndDrain(new AbortController().signal)).rejects.toThrow() - expect(conn.getState().status).toBe('disconnected') - await conn.disconnect() -}) diff --git a/src/main/ssh/ssh-connection-file-transfer.test.ts b/src/main/ssh/ssh-connection-file-transfer.test.ts index 610d24c08d92..0d2e830d6bb7 100644 --- a/src/main/ssh/ssh-connection-file-transfer.test.ts +++ b/src/main/ssh/ssh-connection-file-transfer.test.ts @@ -182,6 +182,11 @@ describe('SshConnection', () => { await expect( uploadSession.uploadFile('/tmp/late.txt', '/remote/late.txt') ).rejects.toMatchObject({ name: 'AbortError' }) - expect(uploadFileViaSystemSsh).not.toHaveBeenCalled() + expect(uploadFileViaSystemSsh).toHaveBeenCalledWith( + expect.anything(), + '/tmp/late.txt', + '/remote/late.txt', + expect.objectContaining({ signal: expect.objectContaining({ aborted: true }) }) + ) }) }) diff --git a/src/main/ssh/ssh-connection-file-transfers.ts b/src/main/ssh/ssh-connection-file-transfers.ts deleted file mode 100644 index c9cc36dcdd7c..000000000000 --- a/src/main/ssh/ssh-connection-file-transfers.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { SFTPWrapper } from 'ssh2' -import type { SshTarget } from '../../shared/ssh-types' -import type { FileUploadSession } from '../providers/types' -import { - resolveSftpTransferPathIfMapped, - type SftpNamespacePathMapping -} from './sftp-namespace-resolution' -import { - createLinkedSshFileTransferSignal, - raceSftpFileTransferWithAbort -} from './ssh-file-transfer-abort' -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { - downloadFileViaSystemSsh, - uploadDirectoryViaSystemSsh, - uploadFileViaSystemSsh, - writeBufferViaSystemSsh, - writeFileViaSystemSsh, - type SystemSshBuildArgsOptions -} from './ssh-system-fallback' - -export type SshRemoteFileOptions = { - hostPlatform?: RemoteHostPlatform - // Only uploadDirectory and writeFile honor this, and only on the non-Windows ssh2 branch. - sftpNamespace?: SftpNamespacePathMapping -} - -/** What a transfer reads from its connection; getters so each read sees the live transport. */ -export type SshFileTransferHost = { - target: SshTarget - usesSystemSshTransport: () => boolean - systemOperationSignal: () => AbortSignal - systemSshBuildArgsOptions: () => SystemSshBuildArgsOptions - sftp: (signal?: AbortSignal) => Promise -} - -export async function uploadSshDirectory( - host: SshFileTransferHost, - localDir: string, - remoteDir: string, - options?: SshRemoteFileOptions & { signal?: AbortSignal } -): Promise { - // Why: relay-deploy timeout and connection teardown are independent owners; either must stop a transfer that could outlive its lock. - const linkedSignal = createLinkedSshFileTransferSignal( - [host.systemOperationSignal(), options?.signal].filter( - (signal): signal is AbortSignal => signal !== undefined - ) - ) - try { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp(linkedSignal.signal) - const swallowLateSftpError = (): void => {} - let sftpEndRequested = false - const endSftp = (): void => { - if (!sftpEndRequested) { - sftpEndRequested = true - sftp.end() - } - } - sftp.on('error', swallowLateSftpError) - sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) - try { - // Why: resolve on the same session that transfers — a later session is not authoritative for this one's namespace. - const transfer = (async (): Promise => { - const targetDir = await resolveSftpTransferPathIfMapped(sftp, remoteDir, options) - linkedSignal.signal.throwIfAborted() - const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') - await uploadDirectory(sftp, localDir, targetDir, localDir, { - signal: linkedSignal.signal - }) - })() - await raceSftpFileTransferWithAbort(transfer, linkedSignal.signal, (onClose) => { - sftp.once('close', onClose) - endSftp() - return () => sftp.removeListener('close', onClose) - }) - } finally { - endSftp() - } - return - } - await uploadDirectoryViaSystemSsh(host.target, localDir, remoteDir, { - signal: linkedSignal.signal, - hostPlatform: options?.hostPlatform, - ...host.systemSshBuildArgsOptions() - }) - } finally { - linkedSignal.dispose() - } -} - -export async function downloadSshFile( - host: SshFileTransferHost, - remotePath: string, - localPath: string, - options?: SshRemoteFileOptions -): Promise { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp() - try { - const { fastGetViaSftp } = await import('../providers/ssh-filesystem-provider-sftp') - await fastGetViaSftp(sftp, remotePath, localPath) - } finally { - sftp.end() - } - return - } - await downloadFileViaSystemSsh(host.target, remotePath, localPath, { - signal: host.systemOperationSignal(), - hostPlatform: options?.hostPlatform, - ...host.systemSshBuildArgsOptions() - }) -} - -export async function createSshFileUploadSession( - host: SshFileTransferHost, - options?: SshRemoteFileOptions -): Promise { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp() - const { uploadFile } = await import('./sftp-upload') - return { - uploadFile: (localPath, remotePath, uploadOptions) => - uploadFile(sftp, localPath, remotePath, uploadOptions), - close: () => sftp.end() - } - } - // Why: disconnect replaces the connection controller, so an existing import session must stay bound to the signal and SSH config it opened with. - const signal = host.systemOperationSignal() - const buildArgsOptions = host.systemSshBuildArgsOptions() - return { - uploadFile: (localPath, remotePath, uploadOptions) => - uploadFileViaSystemSsh(host.target, localPath, remotePath, { - signal, - hostPlatform: options?.hostPlatform, - exclusive: uploadOptions?.exclusive, - ...buildArgsOptions - }), - close: () => {} - } -} - -export async function writeSshFile( - host: SshFileTransferHost, - remotePath: string, - contents: string, - options?: SshRemoteFileOptions & { signal?: AbortSignal } -): Promise { - // Keep package/version writes under the same dual cancellation contract as uploads. - const linkedSignal = createLinkedSshFileTransferSignal( - [host.systemOperationSignal(), options?.signal].filter( - (signal): signal is AbortSignal => signal !== undefined - ) - ) - try { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp(linkedSignal.signal) - const swallowLateSftpError = (): void => {} - let sftpEndRequested = false - const endSftp = (): void => { - if (!sftpEndRequested) { - sftpEndRequested = true - sftp.end() - } - } - sftp.on('error', swallowLateSftpError) - sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) - try { - // Why: resolve on the same session that writes — a later session is not authoritative for this one's namespace. - const write = (async (): Promise => { - const targetPath = await resolveSftpTransferPathIfMapped(sftp, remotePath, options) - linkedSignal.signal.throwIfAborted() - const { writeStringViaSftp } = await import('./sftp-upload') - await writeStringViaSftp(sftp, targetPath, contents) - })() - await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => { - sftp.once('close', onClose) - endSftp() - return () => sftp.removeListener('close', onClose) - }) - } finally { - endSftp() - } - return - } - await writeFileViaSystemSsh(host.target, remotePath, contents, { - signal: linkedSignal.signal, - hostPlatform: options?.hostPlatform, - ...host.systemSshBuildArgsOptions() - }) - } finally { - linkedSignal.dispose() - } -} - -export async function writeSshBuffer( - host: SshFileTransferHost, - remotePath: string, - contents: Buffer, - options?: SshRemoteFileOptions & { append?: boolean; exclusive?: boolean } -): Promise { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp() - try { - const { uploadBuffer } = await import('./sftp-upload') - await uploadBuffer(sftp, contents, remotePath, options) - } finally { - sftp.end() - } - return - } - await writeBufferViaSystemSsh(host.target, remotePath, contents, { - signal: host.systemOperationSignal(), - hostPlatform: options?.hostPlatform, - append: options?.append, - exclusive: options?.exclusive, - ...host.systemSshBuildArgsOptions() - }) -} diff --git a/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts b/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts index 68d3b6ec19a6..9dd1e6d22a2a 100644 --- a/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts +++ b/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts @@ -16,7 +16,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' // Type-only, so it is erased before vi.mock's hoisted factory runs. import type * as SshConfigParser from './ssh-config-parser' -import type * as Ssh2 from 'ssh2' const VALID_ED25519_HOST_KEY = Buffer.from( 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', @@ -32,8 +31,8 @@ let eventHandlers: Map void>> let presentedHostKey: Buffer let hostKeyAccepted: boolean | undefined -vi.mock('ssh2', async (importOriginal) => { - const { utils } = await importOriginal() +vi.mock('ssh2', () => { + const utils = { parseKey: vi.fn(() => new Error('parse failed')) } class MockSshClient { setNoDelay = vi.fn() _sock: Socket | undefined = new Socket() diff --git a/src/main/ssh/ssh-connection-reconnect-ladder.test.ts b/src/main/ssh/ssh-connection-reconnect-ladder.test.ts index 42d61b4d92d3..d62856611bad 100644 --- a/src/main/ssh/ssh-connection-reconnect-ladder.test.ts +++ b/src/main/ssh/ssh-connection-reconnect-ladder.test.ts @@ -256,41 +256,6 @@ describe('SshConnection', () => { } }) - it('recovers the same owner after a temporary DNS resolution failure', async () => { - vi.useFakeTimers() - try { - const states: string[] = [] - const conn = new SshConnection( - createTarget(), - createCallbacks({ - onStateChange: vi.fn((_id, state) => states.push(state.status)) - }) - ) - await connectWithFakeTimers(conn) - const connectedGeneration = conn.getTransportGeneration() - const dnsFailure = Object.assign( - new Error('getaddrinfo EAI_AGAIN temporary failure in name resolution'), - { code: 'EAI_AGAIN' } - ) - ssh2Mock.connectSequence = [dnsFailure, 'ready'] - - emitSshEvent('close') - await advanceToNextSshClient(RECONNECT_BACKOFF_MS[0]) - - expect(conn.getState().status).toBe('reconnecting') - expect(states).not.toContain('error') - expect(states).not.toContain('reconnection-failed') - - await advanceToNextSshClient(RECONNECT_BACKOFF_MS[1]) - - expect(conn.getState().status).toBe('connected') - expect(conn.getTransportGeneration()).toBeGreaterThan(connectedGeneration) - expect(clientInstances).toHaveLength(3) - } finally { - vi.useRealTimers() - } - }) - it('keeps a system-transport target on the ladder after a probe timeout', async () => { vi.useFakeTimers() try { diff --git a/src/main/ssh/ssh-connection-system-work-drain.test.ts b/src/main/ssh/ssh-connection-system-work-drain.test.ts deleted file mode 100644 index e4785495b204..000000000000 --- a/src/main/ssh/ssh-connection-system-work-drain.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { - createCallbacks, - createResolvedConfig, - createTarget, - fenceSshConnectionWork -} from './ssh-connection-test-fixtures' -import { resetSshConnectionMocks } from './ssh-connection-test-harness' -import { resolveWithSshG } from './ssh-config-parser' -import { - downloadFileViaSystemSsh, - uploadDirectoryViaSystemSsh, - uploadFileViaSystemSsh, - writeFileViaSystemSsh, - writeBufferViaSystemSsh -} from './ssh-system-fallback' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) - -beforeEach(resetSshConnectionMocks) - -it('holds an admitted system upload session across reset until close and uploads settle', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) - const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) - await conn.connect() - const session = await conn.openFileUploadSession() - const fence = fenceSshConnectionWork(conn) - await expect(conn.openFileUploadSession()).rejects.toThrow('admission_closed') - await session.uploadFile('/first', '/remote-first') - const pending = Promise.withResolvers() - vi.mocked(uploadFileViaSystemSsh).mockReturnValueOnce(pending.promise) - const upload = session.uploadFile('/second', '/remote-second') - session.close() - await expect(session.uploadFile('/third', '/remote-third')).rejects.toThrow('session_closed') - expect(() => fence.assertDrained()).toThrow('not_drained') - const draining = fence.drain(new AbortController().signal) - expect(conn.getState().status).toBe('connected') - pending.resolve() - await upload - await draining - fence.assertDrained() - expect(uploadFileViaSystemSsh).toHaveBeenCalledTimes(2) -}) - -it.each(['download', 'upload', 'writeFile', 'writeBuffer'])( - 'drains the whole admitted system SSH %s without canceling it', - async (kind) => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) - const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) - await conn.connect() - const pending = Promise.withResolvers() - const invoke = () => { - if (kind === 'download') { - return conn.downloadFile('/remote', '/local') - } - if (kind === 'upload') { - return conn.uploadDirectory('/local', '/remote') - } - if (kind === 'writeFile') { - return conn.writeFile('/remote', 'text') - } - return conn.writeBuffer('/remote', Buffer.from('bytes')) - } - const operation = - kind === 'download' - ? downloadFileViaSystemSsh - : kind === 'upload' - ? uploadDirectoryViaSystemSsh - : kind === 'writeFile' - ? writeFileViaSystemSsh - : writeBufferViaSystemSsh - vi.mocked(operation).mockReturnValueOnce(pending.promise) - const running = invoke() - const fence = fenceSshConnectionWork(conn) - const done = vi.fn() - const draining = fence.drain(new AbortController().signal).then(done) - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - await expect(invoke()).rejects.toThrow('admission_closed') - expect(operation).toHaveBeenCalledTimes(1) - expect(conn.getState().status).toBe('connected') - pending.resolve() - await running - await draining - fence.assertDrained() - } -) diff --git a/src/main/ssh/ssh-connection-test-fixtures.ts b/src/main/ssh/ssh-connection-test-fixtures.ts index dce4a05cb98c..cfed6a010618 100644 --- a/src/main/ssh/ssh-connection-test-fixtures.ts +++ b/src/main/ssh/ssh-connection-test-fixtures.ts @@ -1,8 +1,7 @@ import { EventEmitter } from 'node:events' import { vi } from 'vitest' import type { Mock } from 'vitest' -import type { SshConnection, SshConnectionCallbacks } from './ssh-connection' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' +import type { SshConnectionCallbacks } from './ssh-connection' import type { SshResolvedConfig } from './ssh-config-parser' import type { SshTarget } from '../../shared/ssh-types' @@ -125,9 +124,3 @@ export function createFailingSystemSshProcess(code: number): MockSystemSshProces }) return proc } - -// The public reset fence lands with T3; until then the connection's own ledger fence drains the same work. -export function fenceSshConnectionWork(conn: SshConnection) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SshConnection owns exactly one private workLedger. - return (conn as unknown as { workLedger: SshConnectionWorkLedger }).workLedger.fenceForReset() -} diff --git a/src/main/ssh/ssh-connection-transport-closure.test.ts b/src/main/ssh/ssh-connection-transport-closure.test.ts deleted file mode 100644 index 858c264bf2fd..000000000000 --- a/src/main/ssh/ssh-connection-transport-closure.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { createCallbacks, createResolvedConfig, createTarget } from './ssh-connection-test-fixtures' -import { resetSshConnectionMocks } from './ssh-connection-test-harness' -import { resolveWithSshG } from './ssh-config-parser' -import type { SshTransportCloseLedger } from './ssh-transport-close-ledger' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) -beforeEach(resetSshConnectionMocks) -afterEach(() => { - vi.restoreAllMocks() - vi.useRealTimers() -}) - -async function fixture() { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - const closed = vi.fn() - const remove = conn.subscribeTransportClosure(closed) - await conn.connect() - const client = conn.getClient()! - vi.spyOn(client, 'end').mockImplementation(() => client) - return { conn, client, closed, remove } -} - -it('notifies only after disposal and physical client closure, including late subscribers', async () => { - const f = await fixture() - expect(f.closed).not.toHaveBeenCalled() - await f.conn.disconnect() - expect(f.closed).not.toHaveBeenCalled() - f.client.emit('close') - expect(f.closed).toHaveBeenCalledOnce() - const late = vi.fn() - f.conn.subscribeTransportClosure(late) - expect(late).toHaveBeenCalledOnce() - f.client.emit('close') - expect(f.closed).toHaveBeenCalledOnce() - expect(late).toHaveBeenCalledOnce() -}) - -it('remembers physical closure before disposal but does not report it early', async () => { - const f = await fixture() - f.client.emit('close') - expect(f.closed).not.toHaveBeenCalled() - await f.conn.disconnect() - expect(f.closed).toHaveBeenCalledOnce() -}) - -it('holds notification through tracked work and both forwarded resources', async () => { - const f = await fixture() - const pending = Promise.withResolvers() - const operation = f.conn.prepareForwardRoute(() => pending.promise) - const socket = new EventEmitter() - const channel = new EventEmitter() - f.client.forwardOut = vi.fn((_a, _b, _c, _d, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback?.(undefined, channel as never) - return f.client - }) - f.conn.forwardOut(f.client, socket, '127.0.0.1', 0, 'host', 443, vi.fn()) - await f.conn.disconnect() - f.client.emit('close') - channel.emit('close') - expect(f.closed).not.toHaveBeenCalled() - socket.emit('close') - expect(f.closed).not.toHaveBeenCalled() - pending.resolve() - await operation - expect(f.closed).toHaveBeenCalledOnce() -}) - -it('waits for every allocated proxy even after its active pointer is cleared', async () => { - const f = await fixture() - const proxy = new EventEmitter() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads SshConnection's own private transport close ledger. - const ledger = (f.conn as unknown as { transportCloseLedger: SshTransportCloseLedger }) - .transportCloseLedger - ledger.track(proxy) - await f.conn.disconnect() - f.client.emit('close') - expect(f.closed).not.toHaveBeenCalled() - proxy.emit('close') - expect(f.closed).toHaveBeenCalledOnce() -}) - -it('unsubscribes without changing ordinary disconnection', async () => { - const f = await fixture() - f.remove() - await f.conn.disconnect() - f.client.emit('close') - expect(f.closed).not.toHaveBeenCalled() -}) - -it('isolates observer failures from disconnect and other observers', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - conn.subscribeTransportClosure(() => { - throw new Error('observer') - }) - const observed = vi.fn() - conn.subscribeTransportClosure(observed) - await expect(conn.disconnect()).resolves.toBeUndefined() - expect(observed).toHaveBeenCalledOnce() -}) - -it('never treats unproven system SSH cleanup as physical closure', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) - const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) - const observed = vi.fn() - conn.subscribeTransportClosure(observed) - await conn.connect() - await conn.disconnect() - expect(observed).not.toHaveBeenCalled() -}) - -it.each(['initial', 'reconnect', 'system'] as const)( - 'waits for active %s allocation work', - async (kind) => { - const f = kind === 'reconnect' ? await fixture() : null - const conn = - f?.conn ?? new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - const observed = vi.fn() - conn.subscribeTransportClosure(observed) - const config = Promise.withResolvers() - vi.mocked(resolveWithSshG).mockReturnValueOnce(config.promise) - const opening = ( - kind === 'initial' - ? conn.connect() - : kind === 'reconnect' - ? conn.reconnect() - : conn.connectViaSystemSsh() - ).catch(() => {}) - await conn.disconnect() - f?.client.emit('close') - expect(observed).not.toHaveBeenCalled() - config.resolve(null) - await opening - expect(observed).toHaveBeenCalledTimes(kind === 'system' ? 0 : 1) - } -) - -it('retains failed ledger drainage rather than signaling successful closure', async () => { - const f = await fixture() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads SshConnection's own private work ledger. - const work = (f.conn as unknown as { workLedger: SshConnectionWorkLedger }).workLedger - const opening = work.beginChannelOpen() - work.fenceForReset() - opening.close(new Error('unproven channel')) - await f.conn.disconnect() - f.client.emit('close') - expect(f.closed).not.toHaveBeenCalled() -}) - -it('refuses fresh forwarding work after notifying physical closure', async () => { - const f = await fixture() - await f.conn.disconnect() - f.client.emit('close') - expect(f.closed).toHaveBeenCalledOnce() - const allocate = vi.fn(() => new EventEmitter()) - const prepare = vi.fn(async () => {}) - expect(() => f.conn.openForwardSocket(allocate)).toThrow( - expect.objectContaining({ name: 'AbortError' }) - ) - await expect(f.conn.prepareForwardRoute(prepare)).rejects.toMatchObject({ name: 'AbortError' }) - expect(allocate).not.toHaveBeenCalled() - expect(prepare).not.toHaveBeenCalled() -}) diff --git a/src/main/ssh/ssh-connection-transport-closure.ts b/src/main/ssh/ssh-connection-transport-closure.ts deleted file mode 100644 index 3a92bc29b424..000000000000 --- a/src/main/ssh/ssh-connection-transport-closure.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * One-shot subscribers told when every transport resource a connection allocated has physically - * closed and its tracked work has drained. That is local lifetime evidence only: it never proves a - * remote process exited. - */ -export class SshTransportClosureSubscribers { - private readonly subscribers = new Set<() => void>() - - constructor(private readonly isPhysicallyClosed: () => boolean) {} - - subscribe(onClosed: () => void): () => void { - this.subscribers.add(onClosed) - this.notify() - return () => { - this.subscribers.delete(onClosed) - } - } - - notify(): void { - if (!this.subscribers.size || !this.isPhysicallyClosed()) { - return - } - const subscribers = [...this.subscribers] - this.subscribers.clear() - for (const onClosed of subscribers) { - try { - onClosed() - } catch { - // Observers must not interrupt physical transport cleanup. - } - } - } -} diff --git a/src/main/ssh/ssh-connection-work-drain.test.ts b/src/main/ssh/ssh-connection-work-drain.test.ts deleted file mode 100644 index 24448fe7b064..000000000000 --- a/src/main/ssh/ssh-connection-work-drain.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { EventEmitter } from 'node:events' -import { beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { - createCallbacks, - createTarget, - fenceSshConnectionWork -} from './ssh-connection-test-fixtures' -import { - resetSshConnectionMocks, - pendingSftpCallback, - ssh2Mock -} from './ssh-connection-test-harness' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) - -beforeEach(resetSshConnectionMocks) - -it('does not automatically replace an explicitly single-lifetime SSH transport', async () => { - const options = { automaticReconnect: false } - const conn = new SshConnection(createTarget(), createCallbacks(), options) - options.automaticReconnect = true - await conn.connect() - const client = conn.getClient()! - vi.useFakeTimers() - try { - client.emit('close') - expect(conn.getClient()).toBeNull() - expect(conn.getState().status).toBe('disconnected') - expect(vi.getTimerCount()).toBe(0) - await vi.advanceTimersByTimeAsync(60_000) - expect(conn.getClient()).toBeNull() - expect(conn.getState().reconnectAttempt).toBe(0) - } finally { - await conn.disconnect() - vi.useRealTimers() - } -}) - -it('accounts for forward-route startup before reset and blocks new socket factories', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const release = Promise.withResolvers() - const preparing = conn.prepareForwardRoute(async () => { - await release.promise - return conn.openForwardSocket(() => new EventEmitter()) - }) - const fence = fenceSshConnectionWork(conn) - const forbidden = vi.fn(() => new EventEmitter()) - expect(() => conn.openForwardSocket(forbidden)).toThrow('admission_closed') - expect(forbidden).not.toHaveBeenCalled() - expect(() => fence.assertDrained()).toThrow('not_drained') - release.resolve() - const admitted = await preparing - expect(() => fence.assertDrained()).toThrow('not_drained') - admitted.emit('close') - await fence.drain(new AbortController().signal) -}) - -it('routes actual connection forwarding through admission and rejects a replaced client', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - const channel = new EventEmitter() - const socket = new EventEmitter() - client.forwardOut = vi.fn((_a, _b, _c, _d, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback?.(undefined, channel as never) - return client - }) - conn.forwardOut(client, socket, '127.0.0.1', 0, 'remote', 443, vi.fn()) - const fence = fenceSshConnectionWork(conn) - expect(() => - conn.forwardOut(client, new EventEmitter(), '127.0.0.1', 0, 'remote', 443, vi.fn()) - ).toThrow('admission_closed') - expect(() => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a stand-in for a different ssh2 client; only its identity is compared. - conn.forwardOut({} as never, new EventEmitter(), '127.0.0.1', 0, 'remote', 443, vi.fn()) - ).toThrow('client_changed') - channel.emit('close') - expect(() => fence.assertDrained()).toThrow('not_drained') - socket.emit('close') - await fence.drain(new AbortController().signal) - expect(client.forwardOut).toHaveBeenCalledOnce() -}) - -it('tracks exact remote socket forwarding through a late open and physical close', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - let accept: Parameters[1] | undefined - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - accept = callback - return client - }) - const received = vi.fn() - conn.forwardStreamLocal(client, '/saved/incumbent.sock', received) - expect(client.openssh_forwardOutStreamLocal).toHaveBeenCalledWith( - '/saved/incumbent.sock', - expect.any(Function) - ) - const fence = fenceSshConnectionWork(conn) - expect(() => fence.assertDrained()).toThrow('not_drained') - expect(() => conn.forwardStreamLocal(client, '/other.sock', vi.fn())).toThrow('admission_closed') - const channel = new EventEmitter() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - accept!(undefined, channel as never) - expect(received).toHaveBeenCalledWith(undefined, channel) - expect(() => fence.assertDrained()).toThrow('not_drained') - channel.emit('close') - await fence.drain(new AbortController().signal) - expect(client.openssh_forwardOutStreamLocal).toHaveBeenCalledOnce() -}) - -it('rejects stale clients and non-Unix endpoints before remote socket forwarding', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - client.openssh_forwardOutStreamLocal = vi.fn() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a stand-in for a different ssh2 client; only its identity is compared. - expect(() => conn.forwardStreamLocal({} as never, '/saved.sock', vi.fn())).toThrow( - 'client_changed' - ) - for (const endpoint of ['relative.sock', '\\\\.\\pipe\\saved', '/saved\0.sock']) { - expect(() => conn.forwardStreamLocal(client, endpoint, vi.fn())).toThrow('endpoint_invalid') - } - expect(client.openssh_forwardOutStreamLocal).not.toHaveBeenCalled() -}) - -it('does not treat an uncertain remote socket open as drained', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - client.openssh_forwardOutStreamLocal = vi.fn(() => { - throw new Error('uncertain-open') - }) - expect(() => conn.forwardStreamLocal(client, '/saved.sock', vi.fn())).toThrow('uncertain-open') - const fence = fenceSshConnectionWork(conn) - await expect(fence.drain(new AbortController().signal)).rejects.toThrow('uncertain-open') -}) - -it('surfaces remote socket extension refusal without a fallback', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - let accept: Parameters[1] | undefined - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - accept = callback - return client - }) - const received = vi.fn() - conn.forwardStreamLocal(client, '/saved.sock', received) - const fence = fenceSshConnectionWork(conn) - const refused = new Error('extension unsupported') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh2 passes no channel on a refused open; the callback type omits that case. - accept!(refused, undefined as never) - expect(received).toHaveBeenCalledWith(refused, undefined) - await expect(fence.drain(new AbortController().signal)).rejects.toThrow('extension unsupported') - expect(client.openssh_forwardOutStreamLocal).toHaveBeenCalledOnce() -}) - -it('retains remote socket channel errors through physical close', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - const channel = new EventEmitter() - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback(undefined, channel as never) - return client - }) - conn.forwardStreamLocal(client, '/saved.sock', vi.fn()) - const fence = fenceSshConnectionWork(conn) - channel.emit('error', new Error('connection lost')) - channel.emit('close') - await expect(fence.drain(new AbortController().signal)).rejects.toThrow('connection lost') -}) - -it('keeps the upload session SFTP channel tracked after logical close', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - ssh2Mock.sftpBehavior = 'pending' - const opening = conn.openFileUploadSession() - const fence = fenceSshConnectionWork(conn) - const channel = Object.assign(new EventEmitter(), { end: vi.fn() }) - pendingSftpCallback?.(undefined, channel) - const session = await opening - session.close() - expect(channel.end).toHaveBeenCalledOnce() - expect(() => fence.assertDrained()).toThrow('not_drained') - channel.emit('close') - await fence.drain(new AbortController().signal) - fence.assertDrained() -}) - -it('accounts for an SFTP open begun before fencing until its channel physically closes', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - ssh2Mock.sftpBehavior = 'pending' - const opening = conn.sftp() - const fence = fenceSshConnectionWork(conn) - const done = vi.fn() - const draining = fence.drain(new AbortController().signal).then(done) - const channel = Object.assign(new EventEmitter(), { end: vi.fn() }) - pendingSftpCallback?.(undefined, channel) - await opening - expect(done).not.toHaveBeenCalled() - expect(channel.end).not.toHaveBeenCalled() - await expect(conn.sftp()).rejects.toThrow('admission_closed') - channel.emit('close') - await draining - fence.assertDrained() -}) - -it('retains an opening timeout as unverifiable and never force-closes it to make drain pass', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - ssh2Mock.sftpBehavior = 'pending' - vi.useFakeTimers() - try { - const opening = conn.sftp().catch((error: unknown) => error) - const fence = fenceSshConnectionWork(conn) - const draining = expect(fence.drain(new AbortController().signal)).rejects.toThrow('timed out') - await vi.advanceTimersByTimeAsync(30_000) - await opening - await draining - await expect(fence.drain(new AbortController().signal)).rejects.toThrow('timed out') - expect(conn.getState().status).toBe('connected') - } finally { - vi.useRealTimers() - } -}) diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 0fee9e369042..f75c4b4f172b 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -1,34 +1,6 @@ /* eslint-disable max-lines -- Why: SSH connection lifecycle, credential retries, reconnect policy, and transport fallback are intentionally co-located so state transitions stay auditable in one file. */ import * as net from 'node:net' import { Client as SshClient } from 'ssh2' -import { sshProxyRouteDigest, type SshConnectionDestination } from './ssh-connection-destination' -import { - SshConnectionWorkLedger, - type SshConnectionWorkChannel -} from './ssh-connection-work-ledger' -import { SshTransportCloseLedger } from './ssh-transport-close-ledger' -import { SshTransportClosureSubscribers } from './ssh-connection-transport-closure' -import { disconnectAndAwaitSshTransportClose } from './ssh-connection-close-drain' -import { openTrackedSshUploadSession } from './ssh-upload-session-lifetime' -import { - createSshFileUploadSession, - downloadSshFile, - uploadSshDirectory, - writeSshBuffer, - writeSshFile, - type SshFileTransferHost, - type SshRemoteFileOptions -} from './ssh-connection-file-transfers' -import { - assertSshForwardClient, - assertSshStreamLocalForwardAllowed, - forwardTrackedSshChannel, - forwardTrackedSshStreamLocalChannel -} from './ssh-forward-channel-lifetime' -import { - openTrackedSshSocket, - trackSshConnectionChannelLifetime -} from './ssh-connection-channel-lifetime' import type { ChildProcess } from 'node:child_process' import type { ClientChannel, @@ -44,6 +16,11 @@ import { getOrcaControlSocketPath, spawnSystemSsh, spawnSystemSshCommand, + downloadFileViaSystemSsh, + uploadDirectoryViaSystemSsh, + uploadFileViaSystemSsh, + writeBufferViaSystemSsh, + writeFileViaSystemSsh, type SystemSshBuildArgsOptions, type SystemSshProcess } from './ssh-system-fallback' @@ -106,9 +83,18 @@ import { requiresSystemSshForSecurityKey, shouldUseSystemSshTransport } from './ssh-transport-selection' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { + resolveSftpTransferPathIfMapped, + type SftpNamespacePathMapping +} from './sftp-namespace-resolution' import type { FileUploadSession } from '../providers/types' -import { openSshSessionChannelWithRetry, waitForSshChannelOpen } from './ssh-channel-open' +import { isSshSessionLimitError } from './ssh-session-limit-error' import { withTimeout } from '../../shared/promise-timeout-fallback' +import { + createLinkedSshFileTransferSignal, + raceSftpFileTransferWithAbort +} from './ssh-file-transfer-abort' export type { SshConnectionCallbacks } from './ssh-connection-utils' type HostKeyTrustSources = { @@ -118,6 +104,12 @@ type HostKeyTrustSources = { trustedHostKeys: Awaited> } +type SshRemoteFileOptions = { + hostPlatform?: RemoteHostPlatform + // Only uploadDirectory and writeFile honor this, and only on the non-Windows ssh2 branch. + sftpNamespace?: SftpNamespacePathMapping +} + /** Bounds the trust-source reads that run before the handshake, which nothing else times out. */ const HOST_KEY_SOURCE_READ_TIMEOUT_MS = 5_000 // Counts every INFO_REQUEST of the handshake, so it must cover each partial-success stage the auth @@ -126,6 +118,13 @@ const SSH_KEYBOARD_INTERACTIVE_MAX_ROUNDS = 8 const SSH_KEYBOARD_INTERACTIVE_READY_TIMEOUT_MS = SSH_CREDENTIAL_TIMEOUT_MS + 5_000 const SSH_KEYBOARD_INTERACTIVE_MAX_PROMPTS = 8 +// Upper bound on waiting for an aborted channel's open/close to settle before rejecting anyway. +const ABORTED_CHANNEL_CLOSE_GRACE_MS = 5_000 + +// Why: MaxSessions servers can transiently refuse a channel open; a refused open never ran the command, so retry is safe. +const SESSION_LIMIT_OPEN_RETRIES = 4 +const SESSION_LIMIT_OPEN_RETRY_DELAY_MS = 150 + function cloneResolvedConfig(config: SshResolvedConfig | null): SshResolvedConfig | null { if (!config) { return null @@ -201,37 +200,6 @@ export class SshConnection { private useSystemSshTransport = false private credentialAbortController = new AbortController() private readonly pendingSsh2Clients = new Set() - // Local lifetime evidence only: a closed channel or client never proves a remote process exited. - private readonly workLedger = new SshConnectionWorkLedger( - () => this.transportClosure.notify(), - () => { - if (this.disposed) { - throw createSshOperationAbortError() - } - } - ) - private readonly transportCloseLedger = new SshTransportCloseLedger(() => - this.transportClosure.notify() - ) - private readonly transportClosure = new SshTransportClosureSubscribers( - () => - this.disposed && - this.state.status === 'disconnected' && - this.activeConnectCalls === 0 && - this.pendingSsh2Clients.size === 0 && - !this.unprovenStartupTransport && - this.transportCloseLedger.isClosed() && - this.workLedger.isDrained() - ) - private readonly automaticReconnect: boolean - // Set once drained for close; no reconnect or new transport may replace the fenced one. - private resetConnectionFenced = false - private activeConnectCalls = 0 - // Sticky: a system-ssh child's transport closure cannot be proven from here. - private unprovenStartupTransport = false - private executionDestination: - | { client: SshClient; generation: number; destination: SshConnectionDestination } - | undefined private state: SshConnectionState private callbacks: SshConnectionCallbacks private target: SshTarget @@ -246,12 +214,7 @@ export class SshConnection { private hostKeyFingerprint: string | undefined private connectGeneration = 0 - constructor( - target: SshTarget, - callbacks: SshConnectionCallbacks, - options: { automaticReconnect?: boolean } = {} - ) { - this.automaticReconnect = options.automaticReconnect !== false + constructor(target: SshTarget, callbacks: SshConnectionCallbacks) { this.target = target this.callbacks = callbacks this.state = { @@ -269,60 +232,9 @@ export class SshConnection { getConnectGeneration(): number { return this.connectGeneration } - /** Equals the connect generation; later slices name it by the transport it identifies. */ - getTransportGeneration(): number { - return this.connectGeneration - } - /** The ssh2 destination proven by the current handshake; undefined on system SSH or when stale. */ - getExecutionDestination(): SshConnectionDestination | undefined { - const captured = this.executionDestination - return !this.disposed && - !this.useSystemSshTransport && - this.state.status === 'connected' && - captured?.client === this.client && - captured?.generation === this.connectGeneration - ? captured.destination - : undefined - } - subscribeTransportClosure(onClosed: () => void): () => void { - return this.transportClosure.subscribe(onClosed) - } getClient(): SshClient | null { return this.client } - prepareForwardRoute(prepare: () => Promise): Promise { - return this.workLedger.run(prepare) - } - openForwardSocket(open: () => T): T { - return openTrackedSshSocket(this.workLedger, open, this.reportUnhandledChannelError('socket')) - } - forwardOut( - client: SshClient, - localSocket: SshConnectionWorkChannel, - ...args: Parameters - ): void { - assertSshForwardClient(this.client, client) - const report = this.reportUnhandledChannelError('forward') - forwardTrackedSshChannel(this.workLedger, client, localSocket, report, ...args) - } - forwardStreamLocal( - client: SshClient, - ...args: Parameters - ): void { - assertSshForwardClient(this.client, client) - const usable = - !this.disposed && !this.useSystemSshTransport && this.state.status === 'connected' - assertSshStreamLocalForwardAllowed(usable, args[0]) - const report = this.reportUnhandledChannelError('stream-local forward') - forwardTrackedSshStreamLocalChannel(this.workLedger, client, report, ...args) - } - /** Names the connection and channel kind when a tracked channel's error has no other handler. */ - private reportUnhandledChannelError(kind: string): (error: Error) => void { - return (error) => - console.warn( - `[ssh] Unhandled ${kind} channel error for ${this.target.label}: ${error.message}` - ) - } usesSystemSshTransport(): boolean { return this.useSystemSshTransport } @@ -357,11 +269,7 @@ export class SshConnection { return this.cachedPassphrase != null || this.cachedPassword != null } - exec(cmd: string, options?: SshExecOptions): Promise { - return this.workLedger.run(() => this.execUntracked(cmd, options)) - } - - private async execUntracked(cmd: string, options?: SshExecOptions): Promise { + async exec(cmd: string, options?: SshExecOptions): Promise { if (options?.signal?.aborted) { throw createSshOperationAbortError() } @@ -376,31 +284,21 @@ export class SshConnection { } const client = this.client const remoteCommand = options?.wrapCommand === false ? cmd : wrapRemoteCommandForPosixShell(cmd) - return openSshSessionChannelWithRetry( + return this.openSessionChannelWithRetry( () => - waitForSshChannelOpen( - this.workLedger.beginChannelOpen(), + this.waitForSshCallback( 'SSH exec channel timed out', (callback) => client.exec(remoteCommand, callback), (channel) => channel.close(), options?.signal, - true, - this.reportUnhandledChannelError('exec') + true ), options?.signal ) } /** Interactive login shell over a session channel with pty-req; ssh2 transport only. */ - shell(pty: PseudoTtyOptions, options: ShellOptions = {}): Promise { - // Plain SSH mode's terminals must drain with the rest of this connection's work. - return this.workLedger.run(() => this.shellUntracked(pty, options)) - } - - private async shellUntracked( - pty: PseudoTtyOptions, - options: ShellOptions - ): Promise { + async shell(pty: PseudoTtyOptions, options: ShellOptions = {}): Promise { if (this.useSystemSshTransport) { throw new Error('Interactive SSH shells are not available when using system SSH transport') } @@ -408,26 +306,16 @@ export class SshConnection { throw new Error('Not connected') } const client = this.client - return openSshSessionChannelWithRetry(() => - waitForSshChannelOpen( - this.workLedger.beginChannelOpen(), + return this.openSessionChannelWithRetry(() => + this.waitForSshCallback( 'SSH shell channel timed out', (callback) => client.shell(pty, options, callback), - (channel) => channel.close(), - undefined, - false, - this.reportUnhandledChannelError('shell') + (channel) => channel.close() ) ) } - sftp(options?: AbortSignal | { signal?: AbortSignal }): Promise { - return this.workLedger.run(() => this.sftpUntracked(options)) - } - - private async sftpUntracked( - options?: AbortSignal | { signal?: AbortSignal } - ): Promise { + async sftp(options?: AbortSignal | { signal?: AbortSignal }): Promise { // Why: relay transfers pass a signal directly, while filesystem factories use an options object. const signal = options && 'aborted' in options ? options : options?.signal if (signal?.aborted) { @@ -440,95 +328,373 @@ export class SshConnection { throw new Error('Not connected') } const client = this.client - return openSshSessionChannelWithRetry( + return this.openSessionChannelWithRetry( () => - waitForSshChannelOpen( - this.workLedger.beginChannelOpen(), + this.waitForSshCallback( 'SSH SFTP channel timed out', (callback) => client.sftp(callback), (sftp) => sftp.end(), - signal, - false, - this.reportUnhandledChannelError('sftp') + signal ), signal ) } - uploadDirectory( + private async openSessionChannelWithRetry( + open: () => Promise, + signal?: AbortSignal + ): Promise { + let lastError: unknown + for (let attempt = 0; attempt < SESSION_LIMIT_OPEN_RETRIES; attempt++) { + if (attempt > 0) { + // Why: an abort must release the backoff immediately, not after it. + if (!signal?.aborted) { + await new Promise((resolve) => { + const onDelayDone = (): void => { + clearTimeout(delayTimer) + signal?.removeEventListener('abort', onDelayDone) + resolve() + } + const delayTimer = setTimeout(onDelayDone, SESSION_LIMIT_OPEN_RETRY_DELAY_MS) + signal?.addEventListener('abort', onDelayDone, { once: true }) + }) + } + if (signal?.aborted) { + throw createSshOperationAbortError() + } + } + try { + return await open() + } catch (err) { + if (!isSshSessionLimitError(err)) { + throw err + } + lastError = err + } + } + throw lastError + } + + private waitForSshCallback( + timeoutMessage: string, + register: (callback: (error: Error | undefined, value: T) => void) => void, + cleanupLateValue?: (value: T) => void, + signal?: AbortSignal, + trackRemoteCommandTermination = false + ): Promise { + return new Promise((resolve, reject) => { + type ChannelOpenTerminationError = Error & { sshChannelCloseConfirmed: boolean } + let settled = false + let unconfirmedOpenError: ChannelOpenTerminationError | null = null + const markOpenUnconfirmed = (error: Error): Error => { + if (!trackRemoteCommandTermination) { + return error + } + unconfirmedOpenError = Object.assign(error, { sshChannelCloseConfirmed: false }) + return unconfirmedOpenError + } + // Why: an in-flight open holds a MaxSessions slot; reject the caller now, then settle from the open callback once the late channel closes. + let abortRequested = false + let abortDeadlineTimer: NodeJS.Timeout | undefined + const cleanup = (): void => { + clearTimeout(timer) + clearTimeout(abortDeadlineTimer) + signal?.removeEventListener('abort', onAbort) + } + const onAbort = (): void => { + abortRequested = true + // Why: a hung socket may never invoke the open callback; bound the aborted caller's wait instead of pinning it for CONNECT_TIMEOUT_MS. + abortDeadlineTimer = setTimeout(() => { + settled = true + cleanup() + reject(markOpenUnconfirmed(createSshOperationAbortError())) + }, ABORTED_CHANNEL_CLOSE_GRACE_MS) + } + const timer = setTimeout(() => { + settled = true + cleanup() + reject( + markOpenUnconfirmed( + abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage) + ) + ) + }, CONNECT_TIMEOUT_MS) + const discardLateValue = (value: T, onClose?: () => void): void => { + const emitter = value as Partial & { + resume?: () => void + stderr?: Partial & { resume?: () => void } + } + const swallowLateError = (): void => {} + emitter.on?.('error', swallowLateError) + emitter.stderr?.on?.('error', swallowLateError) + if (onClose) { + emitter.once?.('close', onClose) + } + // Why: ssh2 withholds CHANNEL_CLOSE while discarded exec streams remain unread, and teardown errors have no other owner. + emitter.resume?.() + emitter.stderr?.resume?.() + try { + cleanupLateValue?.(value) + } catch { + /* best effort */ + } + } + const rejectAfterClose = (value: T): void => { + const abortError = markOpenUnconfirmed(createSshOperationAbortError()) + const emitter = value as Partial & { + resume?: () => void + stderr?: { resume?: () => void } + } + let finished = false + const done = (): void => { + if (finished) { + return + } + finished = true + clearTimeout(closeGraceTimer) + emitter.removeListener?.('close', confirmAndDone) + reject(abortError) + } + const confirmAndDone = (): void => { + if (unconfirmedOpenError === abortError) { + unconfirmedOpenError.sshChannelCloseConfirmed = true + } + done() + } + // Why: bounded so a remote that never confirms the close can't hang the aborted operation forever. + const closeGraceTimer = setTimeout(done, ABORTED_CHANNEL_CLOSE_GRACE_MS) + if (typeof emitter.once === 'function') { + emitter.once('close', confirmAndDone) + } + // Why: ssh2 withholds 'close' until the channel's streams are drained; nobody else will read this discarded channel. + discardLateValue(value) + if (typeof emitter.once !== 'function') { + done() + } + } + const finish = (error: Error | undefined, value?: T): void => { + if (settled) { + // Why: ssh2 can invoke the open callback after our timeout rejected; close that late channel so it isn't left open with no owner. + if (!error && value !== undefined) { + discardLateValue(value, () => { + if (unconfirmedOpenError) { + unconfirmedOpenError.sshChannelCloseConfirmed = true + } + }) + } + return + } + settled = true + cleanup() + if (abortRequested) { + if (!error && value !== undefined) { + rejectAfterClose(value) + } else { + reject(createSshOperationAbortError()) + } + return + } + if (error) { + reject(error) + return + } + resolve(value as T) + } + if (signal?.aborted) { + // No open is in flight yet, so failing fast leaks nothing. + cleanup() + reject(createSshOperationAbortError()) + return + } + signal?.addEventListener('abort', onAbort, { once: true }) + + try { + // Why: higher-level channel timers start only after ssh2's open callback; a stale SSH socket can otherwise keep exec/sftp stuck. + register(finish) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + }) + } + + async uploadDirectory( localDir: string, remoteDir: string, options?: SshRemoteFileOptions & { signal?: AbortSignal } ): Promise { - return this.workLedger.run(() => - uploadSshDirectory(this.fileTransferHost(), localDir, remoteDir, options) + // Why: relay-deploy timeout and connection teardown are independent owners; either must stop a transfer that could outlive its lock. + const linkedSignal = createLinkedSshFileTransferSignal( + [this.systemOperationAbortController.signal, options?.signal].filter( + (signal): signal is AbortSignal => signal !== undefined + ) ) + try { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp(linkedSignal.signal) + const swallowLateSftpError = (): void => {} + let sftpEndRequested = false + const endSftp = (): void => { + if (!sftpEndRequested) { + sftpEndRequested = true + sftp.end() + } + } + sftp.on('error', swallowLateSftpError) + sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) + try { + // Why: resolve on the same session that transfers — a later session is not authoritative for this one's namespace. + const transfer = (async (): Promise => { + const targetDir = await resolveSftpTransferPathIfMapped(sftp, remoteDir, options) + linkedSignal.signal.throwIfAborted() + const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') + await uploadDirectory(sftp, localDir, targetDir, localDir, { + signal: linkedSignal.signal + }) + })() + await raceSftpFileTransferWithAbort(transfer, linkedSignal.signal, (onClose) => { + sftp.once('close', onClose) + endSftp() + return () => sftp.removeListener('close', onClose) + }) + } finally { + endSftp() + } + return + } + await uploadDirectoryViaSystemSsh(this.target, localDir, remoteDir, { + signal: linkedSignal.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) + } finally { + linkedSignal.dispose() + } } - downloadFile( + async downloadFile( remotePath: string, localPath: string, options?: SshRemoteFileOptions ): Promise { - return this.workLedger.run(() => - downloadSshFile(this.fileTransferHost(), remotePath, localPath, options) - ) + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + try { + const { fastGetViaSftp } = await import('../providers/ssh-filesystem-provider-sftp') + await fastGetViaSftp(sftp, remotePath, localPath) + } finally { + sftp.end() + } + return + } + await downloadFileViaSystemSsh(this.target, remotePath, localPath, { + signal: this.systemOperationAbortController.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) } - openFileUploadSession(options?: SshRemoteFileOptions): Promise { - return openTrackedSshUploadSession(this.workLedger, () => - createSshFileUploadSession(this.fileTransferHost(), options) - ) + async openFileUploadSession(options?: SshRemoteFileOptions): Promise { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + const { uploadFile } = await import('./sftp-upload') + return { + uploadFile: (localPath, remotePath, uploadOptions) => + uploadFile(sftp, localPath, remotePath, uploadOptions), + close: () => sftp.end() + } + } + // Why: disconnect replaces the connection controller, so an existing import session must stay bound to the signal and SSH config it opened with. + const signal = this.systemOperationAbortController.signal + const buildArgsOptions = this.getSystemSshBuildArgsOptions() + return { + uploadFile: (localPath, remotePath, uploadOptions) => + uploadFileViaSystemSsh(this.target, localPath, remotePath, { + signal, + hostPlatform: options?.hostPlatform, + exclusive: uploadOptions?.exclusive, + ...buildArgsOptions + }), + close: () => {} + } } - writeFile( + async writeFile( remotePath: string, contents: string, options?: SshRemoteFileOptions & { signal?: AbortSignal } ): Promise { - return this.workLedger.run(() => - writeSshFile(this.fileTransferHost(), remotePath, contents, options) + // Keep package/version writes under the same dual cancellation contract as uploads. + const linkedSignal = createLinkedSshFileTransferSignal( + [this.systemOperationAbortController.signal, options?.signal].filter( + (signal): signal is AbortSignal => signal !== undefined + ) ) + try { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp(linkedSignal.signal) + const swallowLateSftpError = (): void => {} + let sftpEndRequested = false + const endSftp = (): void => { + if (!sftpEndRequested) { + sftpEndRequested = true + sftp.end() + } + } + sftp.on('error', swallowLateSftpError) + sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) + try { + // Why: resolve on the same session that writes — a later session is not authoritative for this one's namespace. + const write = (async (): Promise => { + const targetPath = await resolveSftpTransferPathIfMapped(sftp, remotePath, options) + linkedSignal.signal.throwIfAborted() + const { writeStringViaSftp } = await import('./sftp-upload') + await writeStringViaSftp(sftp, targetPath, contents) + })() + await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => { + sftp.once('close', onClose) + endSftp() + return () => sftp.removeListener('close', onClose) + }) + } finally { + endSftp() + } + return + } + await writeFileViaSystemSsh(this.target, remotePath, contents, { + signal: linkedSignal.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) + } finally { + linkedSignal.dispose() + } } - writeBuffer( + async writeBuffer( remotePath: string, contents: Buffer, options?: SshRemoteFileOptions & { append?: boolean; exclusive?: boolean } ): Promise { - return this.workLedger.run(() => - writeSshBuffer(this.fileTransferHost(), remotePath, contents, options) - ) - } - - private fileTransferHost(): SshFileTransferHost { - return { - target: this.target, - usesSystemSshTransport: () => this.useSystemSshTransport, - systemOperationSignal: () => this.systemOperationAbortController.signal, - systemSshBuildArgsOptions: () => this.getSystemSshBuildArgsOptions(), - sftp: (signal) => this.sftp(signal) + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + try { + const { uploadBuffer } = await import('./sftp-upload') + await uploadBuffer(sftp, contents, remotePath, options) + } finally { + sftp.end() + } + return } + await writeBufferViaSystemSsh(this.target, remotePath, contents, { + signal: this.systemOperationAbortController.signal, + hostPlatform: options?.hostPlatform, + append: options?.append, + exclusive: options?.exclusive, + ...this.getSystemSshBuildArgsOptions() + }) } async connect(): Promise { - this.activeConnectCalls++ - try { - await this.connectInitialAttempts() - } finally { - this.activeConnectCalls-- - this.transportClosure.notify() - } - } - - private assertConnectionReplacementAllowed(): void { - if (this.resetConnectionFenced) { - throw new Error('ssh_connection_reset_replacement_refused') - } - } - - private async connectInitialAttempts(): Promise { - this.assertConnectionReplacementAllowed() if (this.disposed) { throw new Error('Connection disposed') } @@ -536,7 +702,6 @@ export class SshConnection { let lastError: Error | null = null for (let attempt = 0; attempt < INITIAL_RETRY_ATTEMPTS; attempt++) { - this.assertConnectionReplacementAllowed() const connectGeneration = ++this.connectGeneration try { await this.attemptConnect(connectGeneration) @@ -654,7 +819,6 @@ export class SshConnection { } private async attemptConnect(connectGeneration = ++this.connectGeneration): Promise { - this.executionDestination = undefined this.credentialAbortController.abort() this.credentialAbortController = new AbortController() this.setState('connecting') @@ -703,10 +867,8 @@ export class SshConnection { // Why: ssh2 doesn't support ProxyCommand/ProxyJump natively; spawn the resolved proxy and pipe its stdin/stdout as config.sock. const effectiveProxy = resolveEffectiveProxy(this.target, resolved) - const proxyRouteDigest = sshProxyRouteDigest(effectiveProxy) if (effectiveProxy) { const proxy = spawnProxyCommand(effectiveProxy, config.host!, config.port!, config.username!) - this.transportCloseLedger.track(proxy.process) this.proxyProcess = proxy.process config.sock = proxy.sock } @@ -719,7 +881,7 @@ export class SshConnection { } try { - await this.doSsh2Connect(config, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(config, connectGeneration) } catch (err) { if (!(err instanceof Error)) { this.proxyProcess?.kill() @@ -795,7 +957,7 @@ export class SshConnection { if (keyConfig.privateKey || keyConfig.password) { this.respawnProxy(keyConfig, effectiveProxy) try { - await this.doSsh2Connect(keyConfig, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(keyConfig, connectGeneration) return } catch (keyErr) { // Same reason as above: the retry re-runs the handshake, so it can be the attempt that @@ -830,7 +992,7 @@ export class SshConnection { this.cachedPassphrase = val keyConfig.passphrase = val this.respawnProxy(keyConfig, effectiveProxy) - await this.doSsh2Connect(keyConfig, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(keyConfig, connectGeneration) return } } @@ -880,7 +1042,7 @@ export class SshConnection { this.cachedPassphrase = val credentialRetryConfig.passphrase = val this.respawnProxy(credentialRetryConfig, effectiveProxy) - await this.doSsh2Connect(credentialRetryConfig, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(credentialRetryConfig, connectGeneration) return } } @@ -895,7 +1057,7 @@ export class SshConnection { this.cachedPassword = val credentialRetryConfig.password = val this.respawnProxy(credentialRetryConfig, effectiveProxy) - await this.doSsh2Connect(credentialRetryConfig, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(credentialRetryConfig, connectGeneration) return } } @@ -906,7 +1068,6 @@ export class SshConnection { } async reconnect(): Promise { - this.assertConnectionReplacementAllowed() if (this.disposed || this.state.status === 'connecting') { return } @@ -923,7 +1084,6 @@ export class SshConnection { } private async doSystemSshProbe(connectGeneration: number): Promise { - this.unprovenStartupTransport = true this.useSystemSshTransport = true this.client = null this.proxyProcess?.kill() @@ -1084,7 +1244,6 @@ export class SshConnection { if (!this.isCurrentConnectAttempt(connectGeneration)) { throw this.createCancelledConnectAttemptError() } - this.unprovenStartupTransport = true const proc = spawnSystemSsh(this.target, this.getSystemSshBuildArgsOptions()) this.systemSsh = proc let settled = false @@ -1175,18 +1334,10 @@ export class SshConnection { options === undefined && Object.keys(buildArgsOptions).length === 0 ? undefined : { ...options, ...buildArgsOptions } - const work = this.workLedger.beginChannelOpen() - let channel: ClientChannel - try { - channel = - commandOptions === undefined - ? spawnSystemSshCommand(this.target, command) - : spawnSystemSshCommand(this.target, command, commandOptions) - trackSshConnectionChannelLifetime(work, channel, this.reportUnhandledChannelError('command')) - } catch (error) { - work.close(error) - throw error - } + const channel = + commandOptions === undefined + ? spawnSystemSshCommand(this.target, command) + : spawnSystemSshCommand(this.target, command, commandOptions) this.systemCommandChannels.add(channel) const onAbort = (): void => { channel.close() @@ -1230,7 +1381,6 @@ export class SshConnection { } this.proxyProcess?.kill() const p = spawnProxyCommand(proxy, config.host!, config.port!, config.username!) - this.transportCloseLedger.track(p.process) this.proxyProcess = p.process config.sock = p.sock } @@ -1283,16 +1433,7 @@ export class SshConnection { return sources } - private async doSsh2Connect( - config: ConnectConfig, - connectGeneration: number, - proxyRouteDigest: string - ): Promise { - const endpoint = { - host: config.host ?? '', - port: config.port ?? 22, - username: config.username ?? '' - } + private async doSsh2Connect(config: ConnectConfig, connectGeneration: number): Promise { const hostKeyResolved = this.hostKeyResolvedConfig const { host: hostKeyLookupHost, isHostKeyAlias } = resolveKnownHostsLookupHost( hostKeyResolved, @@ -1335,7 +1476,6 @@ export class SshConnection { : undefined return new Promise((resolve, reject) => { const client = new SshClient() - this.transportCloseLedger.track(client) this.pendingSsh2Clients.add(client) let settled = false let startupTimer: ReturnType | null = null @@ -1358,7 +1498,6 @@ export class SshConnection { // the live attempt's error, and substituting a new Error drops ssh2's `code`, so a transient // ECONNRESET would stop being classified as retryable. let hostKeyRejection: HostKeyVerificationError | null = null - let acceptedFingerprint: string | undefined // Why the fingerprint is still recorded: the relay uses the negotiated server key to isolate // shared-home install locks without comparing PIDs from an unrelated SSH host. Its format is @@ -1410,7 +1549,6 @@ export class SshConnection { connectGeneration === this.connectGeneration ) { this.hostKeyFingerprint = decision.fingerprint - acceptedFingerprint = decision.fingerprint } if (decision.action === 'reject') { hostKeyRejection = new HostKeyVerificationError( @@ -1509,20 +1647,6 @@ export class SshConnection { } settled = true this.client = client - // Only this successful handshake can publish recovery destination authority. - this.executionDestination = acceptedFingerprint - ? { - client, - generation: connectGeneration, - destination: Object.freeze({ - version: 1, - transport: 'ssh2', - ...endpoint, - hostKeyFingerprint: acceptedFingerprint, - proxyRouteDigest - }) - } - : undefined this.proxyProcess = null this.setupDisconnectHandler(client) cleanupStartupListeners() @@ -1580,7 +1704,6 @@ export class SshConnection { if (this.disposed || this.client !== client) { return } - this.workLedger.markTransportUnverifiable() this.client = null this.scheduleReconnect() } @@ -1591,18 +1714,13 @@ export class SshConnection { return } console.warn(`[ssh] Connection error for ${this.target.label}: ${err.message}`) - this.workLedger.markTransportUnverifiable() this.client = null this.scheduleReconnect() }) } private scheduleReconnect(): void { - if (this.disposed || this.resetConnectionFenced || this.reconnectTimer) { - return - } - if (!this.automaticReconnect) { - this.setState('disconnected') + if (this.disposed || this.reconnectTimer) { return } const decision = this.reconnectLadder.next(Date.now()) @@ -1617,7 +1735,7 @@ export class SshConnection { ) this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = null - if (this.disposed || this.resetConnectionFenced) { + if (this.disposed) { return } await this.runReconnectAttempt() @@ -1625,19 +1743,6 @@ export class SshConnection { } private async runReconnectAttempt(): Promise { - this.activeConnectCalls++ - try { - await this.runTrackedReconnectAttempt() - } finally { - this.activeConnectCalls-- - this.transportClosure.notify() - } - } - - private async runTrackedReconnectAttempt(): Promise { - if (this.resetConnectionFenced) { - return - } const connectGeneration = ++this.connectGeneration try { // Why: reset before connecting so the 'connected' broadcast carries reconnectAttempt=0, which ssh.ts uses to trigger relay re-establishment. @@ -1713,21 +1818,9 @@ export class SshConnection { } async connectViaSystemSsh(): Promise { - this.activeConnectCalls++ - try { - return await this.connectTrackedViaSystemSsh() - } finally { - this.activeConnectCalls-- - this.transportClosure.notify() - } - } - - private async connectTrackedViaSystemSsh(): Promise { - this.assertConnectionReplacementAllowed() if (this.disposed) { throw new Error('Connection disposed') } - this.unprovenStartupTransport = true const connectGeneration = ++this.connectGeneration this.systemSsh?.kill() this.systemSsh = null @@ -1761,7 +1854,6 @@ export class SshConnection { // Why: register the reconnect handler only after handshake succeeds (the onExit above guards with `settled`). proc.onExit(() => { if (!this.disposed && this.systemSsh === proc) { - this.workLedger.markTransportUnverifiable() this.systemSsh = null this.scheduleReconnect() } @@ -1780,60 +1872,7 @@ export class SshConnection { } } - /** Owned migration transport only; local closure never proves remote process exit. */ - async disconnectAndDrain(signal: AbortSignal): Promise { - signal.throwIfAborted() - // An unconnected non-reconnecting owner has nothing live left to fence. - const settledExclusiveStartup = - !this.automaticReconnect && - this.activeConnectCalls === 0 && - this.pendingSsh2Clients.size === 0 && - !this.unprovenStartupTransport - if ( - this.useSystemSshTransport || - this.unprovenStartupTransport || - this.activeConnectCalls > 0 || - (!this.client && !settledExclusiveStartup) - ) { - await this.disconnect() - throw new Error('ssh_connection_close_transport_unproven') - } - let fence: ReturnType - try { - if ( - this.client && - (this.disposed || this.state.status !== 'connected' || this.pendingSsh2Clients.size > 0) - ) { - throw new Error('ssh_connection_reset_transport_not_connected') - } - fence = this.workLedger.fenceForReset() - this.resetConnectionFenced = true - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - } - } catch (error) { - try { - await this.disconnect() - } catch (cleanupError) { - throw new AggregateError([error, cleanupError], 'ssh_connection_close_cleanup_failed') - } - throw error - } - await disconnectAndAwaitSshTransportClose({ - liveResources: [ - ...(this.client ? [this.client] : []), - ...(this.proxyProcess ? [this.proxyProcess] : []) - ], - disconnect: () => this.disconnect(), - transportCloseLedger: this.transportCloseLedger, - fence, - signal - }) - } - async disconnect(): Promise { - this.executionDestination = undefined this.disposed = true this.connectGeneration += 1 if (this.reconnectTimer) { @@ -1862,7 +1901,6 @@ export class SshConnection { this.useSystemSshTransport = false this.reconnectLadder.reset() this.setState('disconnected') - this.transportClosure.notify() } private setState(status: SshConnectionStatus, error?: string): void { diff --git a/src/main/ssh/ssh-forward-channel-lifetime.test.ts b/src/main/ssh/ssh-forward-channel-lifetime.test.ts deleted file mode 100644 index ae9bc18ef76d..000000000000 --- a/src/main/ssh/ssh-forward-channel-lifetime.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { EventEmitter } from 'node:events' -import type { Client, ClientChannel } from 'ssh2' -import { expect, it, vi } from 'vitest' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' -import { forwardTrackedSshChannel } from './ssh-forward-channel-lifetime' - -function fixture() { - const ledger = new SshConnectionWorkLedger() - const local = new EventEmitter() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ledger only needs the channel's EventEmitter surface. - const remote = new EventEmitter() as ClientChannel - let opened!: NonNullable[4]> - const client = { - forwardOut: vi.fn((_a, _b, _c, _d, callback) => { - opened = callback - }) - } - const callback = vi.fn() - const start = () => - forwardTrackedSshChannel( - ledger, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the forwardOut the helper calls. - client as unknown as Client, - local, - undefined, - '127.0.0.1', - 0, - 'remote.internal', - 443, - callback - ) - return { ledger, local, remote, client, callback, start, opened: () => opened } -} - -it('tracks a pending open and both physical ends without terminating either', async () => { - const f = fixture() - f.start() - const fence = f.ledger.fenceForReset() - expect(fence.assertDrained).toThrow('not_drained') - f.opened()(undefined, f.remote) - expect(f.callback).toHaveBeenCalledWith(undefined, f.remote) - f.remote.emit('close') - expect(fence.assertDrained).toThrow('not_drained') - f.local.emit('close') - await fence.drain(new AbortController().signal) -}) - -it('retains late channels after the local socket closes', async () => { - const f = fixture() - f.start() - f.local.emit('close') - const fence = f.ledger.fenceForReset() - f.opened()(undefined, f.remote) - expect(fence.assertDrained).toThrow('not_drained') - f.remote.emit('close') - await fence.drain(new AbortController().signal) -}) - -it('refuses fenced opens before forwarding or subscribing to the new socket', () => { - const f = fixture() - f.ledger.fenceForReset() - expect(f.start).toThrow('admission_closed') - expect(f.client.forwardOut).not.toHaveBeenCalled() - expect(f.local.listenerCount('close')).toBe(0) -}) - -it('retains open failures during reset rather than claiming successful drain', async () => { - const f = fixture() - f.start() - const fence = f.ledger.fenceForReset() - const failure = new Error('forward failed') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh2 passes no channel on a refused open; the callback type omits that case. - f.opened()(failure, undefined as never) - f.local.emit('close') - await expect(fence.drain(new AbortController().signal)).rejects.toBe(failure) -}) - -it('retains synchronous open uncertainty even when reset begins later', async () => { - const f = fixture() - const failure = new Error('transport failed') - f.client.forwardOut.mockImplementation(() => { - throw failure - }) - expect(f.start).toThrow(failure) - f.local.emit('close') - await expect(f.ledger.fenceForReset().drain(new AbortController().signal)).rejects.toBe(failure) -}) - -it('aborting observation preserves the admitted sockets and allows another drain', async () => { - const f = fixture() - f.start() - const fence = f.ledger.fenceForReset() - const observer = new AbortController() - const draining = fence.drain(observer.signal).catch((error: unknown) => error) - const failure = new Error('observer stopped') - observer.abort(failure) - expect(await draining).toBe(failure) - f.opened()(undefined, f.remote) - f.local.emit('close') - f.remote.emit('close') - await fence.drain(new AbortController().signal) -}) diff --git a/src/main/ssh/ssh-forward-channel-lifetime.ts b/src/main/ssh/ssh-forward-channel-lifetime.ts deleted file mode 100644 index 902b40b66eed..000000000000 --- a/src/main/ssh/ssh-forward-channel-lifetime.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Client } from 'ssh2' -import { - trackSshConnectionChannelLifetime, - type SshChannelErrorReporter -} from './ssh-connection-channel-lifetime' -import type { - SshConnectionWorkChannel, - SshConnectionWorkLedger -} from './ssh-connection-work-ledger' - -/** A forward must ride the connection's current client, never one a reconnect replaced. */ -export function assertSshForwardClient(current: Client | null, requested: Client): void { - if (current !== requested) { - throw new Error('ssh_connection_forward_client_changed') - } -} - -/** Remote stream-local forwarding needs a live ssh2 transport and an absolute Unix socket path. */ -export function assertSshStreamLocalForwardAllowed( - transportUsable: boolean, - socketPath: string -): void { - if (!transportUsable) { - throw new Error('ssh_connection_streamlocal_transport_unavailable') - } - if (!socketPath.startsWith('/') || socketPath.includes('\0')) { - throw new Error('ssh_connection_streamlocal_endpoint_invalid') - } -} - -/** The channel remains tracked until physical close, including late open replies. */ -export function forwardTrackedSshStreamLocalChannel( - ledger: SshConnectionWorkLedger, - client: Client, - onUnhandledError: SshChannelErrorReporter | undefined, - ...args: Parameters -): void { - const opening = ledger.beginChannelOpen() - const [socketPath, callback] = args - try { - client.openssh_forwardOutStreamLocal(socketPath, (error, channel) => { - if (error) { - opening.close(error) - } else { - trackSshConnectionChannelLifetime(opening, channel, onUnhandledError) - } - callback(error, channel) - }) - } catch (error) { - opening.markUnverifiable(error instanceof Error ? error : new Error(String(error))) - throw error - } -} - -export function forwardTrackedSshChannel( - ledger: SshConnectionWorkLedger, - client: Client, - localSocket: SshConnectionWorkChannel, - onUnhandledError: SshChannelErrorReporter | undefined, - ...args: Parameters -): void { - const opening = ledger.beginChannelOpen() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), localSocket, onUnhandledError) - const [sourceHost, sourcePort, host, port, callback] = args - try { - client.forwardOut(sourceHost, sourcePort, host, port, (error, channel) => { - if (error) { - opening.close(error) - } else { - trackSshConnectionChannelLifetime(opening, channel, onUnhandledError) - } - callback?.(error, channel) - }) - } catch (error) { - opening.markUnverifiable(error instanceof Error ? error : new Error(String(error))) - throw error - } -} diff --git a/src/main/ssh/ssh-multi-factor-authentication.test.ts b/src/main/ssh/ssh-multi-factor-authentication.test.ts index 4b22c9e6157e..2ee643dea7a9 100644 --- a/src/main/ssh/ssh-multi-factor-authentication.test.ts +++ b/src/main/ssh/ssh-multi-factor-authentication.test.ts @@ -1,6 +1,5 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { homedir, tmpdir } from 'node:os' -import type * as Os from 'node:os' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { Client, @@ -11,16 +10,11 @@ import { type KeyboardAuthContext, type PasswordAuthContext } from 'ssh2' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { SshTarget } from '../../shared/ssh-types' import type { SshResolvedConfig } from './ssh-config-parser' import { buildConnectConfig } from './ssh-connection-utils' -vi.mock('node:os', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, homedir: vi.fn(() => actual.tmpdir()) } -}) - // OpenSSH's default; a host that burns it disconnects before the MFA stage is reached. const MAX_AUTH_TRIES = 6 const PASSWORD = 'stage-one-password' @@ -192,11 +186,19 @@ function connectWithOrcaConfig( describe('multi-stage SSH authentication', () => { let tempDir: string let keyPaths: string[] + let homeEnv: { HOME?: string; USERPROFILE?: string } beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'orca-mfa-')) - // Default-key discovery must never read the developer's SSH credentials. - vi.mocked(homedir).mockReturnValue(tempDir) + // Why: the cases below pass `resolved: null`, so `resolvePrivateKeys` falls through to + // `findDefaultKeyFile`, which reads `~/.ssh/id_*` through `homedir()`. On a developer + // machine that picks up a real key, and an encrypted one makes ssh2 reject with + // "Cannot parse privateKey" before authentication is exercised at all. Hosted CI has no + // key, so this only ever failed locally. Pointing home at the fixture directory keeps + // default-key discovery inside the test's control on every machine. + homeEnv = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE } + process.env.HOME = tempDir + process.env.USERPROFILE = tempDir keyPaths = ['id_a', 'id_b'].map((name) => { const path = join(tempDir, name) writeFileSync(path, utils.generateKeyPairSync('ecdsa', { bits: 256 }).private) @@ -205,7 +207,14 @@ describe('multi-stage SSH authentication', () => { }) afterEach(() => { - vi.mocked(homedir).mockReturnValue(tmpdir()) + for (const key of ['HOME', 'USERPROFILE'] as const) { + const previous = homeEnv[key] + if (previous === undefined) { + delete process.env[key] + } else { + process.env[key] = previous + } + } rmSync(tempDir, { recursive: true, force: true }) }) diff --git a/src/main/ssh/ssh-relay-exec-command.ts b/src/main/ssh/ssh-relay-exec-command.ts index 10b2b4c37634..e7d5fea353ce 100644 --- a/src/main/ssh/ssh-relay-exec-command.ts +++ b/src/main/ssh/ssh-relay-exec-command.ts @@ -47,7 +47,7 @@ export function isUnconfirmedSshCommandTermination( } export async function execCommand( - conn: Pick, + conn: SshConnection, command: string, options?: ExecCommandOptions ): Promise { diff --git a/src/main/ssh/ssh-relay-exec-input.test.ts b/src/main/ssh/ssh-relay-exec-input.test.ts deleted file mode 100644 index c5b6ffdeeb1a..000000000000 --- a/src/main/ssh/ssh-relay-exec-input.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { EventEmitter } from 'node:events' -import { PassThrough, Readable } from 'node:stream' -import { describe, expect, it, vi } from 'vitest' -import { execCommand } from './ssh-relay-exec-command' - -const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) -vi.mock('node:child_process', () => ({ spawn: spawnMock })) -vi.mock('./system-ssh-binary', () => ({ findSystemSsh: () => '/usr/bin/ssh' })) -import { spawnSystemSshCommand } from './system-ssh-command' - -function fakeSshProcess() { - const proc = Object.assign(new EventEmitter(), { - stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn() - }) - spawnMock.mockReturnValue(proc) - let input = '' - proc.stdin.on('data', (chunk) => { - input += chunk.toString() - }) - proc.stdin.on('finish', () => { - proc.stdout.write('answer') - proc.emit('close', 0) - }) - return { proc, input: () => input } -} - -const target = { id: 't', label: 't', host: 'example.test', port: 22, username: 'u' } - -describe('system SSH command input', () => { - it('forwards end-of-input written to the facade to the ssh child', async () => { - const { proc, input } = fakeSshProcess() - const channel = spawnSystemSshCommand(target, 'reader') - const finished = new Promise((resolve) => proc.stdin.once('finish', resolve)) - - channel.end('secret') - await finished - - expect(input()).toBe('secret') - expect(proc.stdin.writableFinished).toBe(true) - }) - - it('streams command input through the real facade and returns the response', async () => { - const { proc, input } = fakeSshProcess() - const channel = spawnSystemSshCommand(target, 'reader') - - await expect( - execCommand({ exec: async () => channel, usesSystemSshTransport: () => true }, 'reader', { - stdin: Readable.from(['secret']) - }) - ).resolves.toBe('answer') - expect(input()).toBe('secret') - expect(proc.stdin.writableFinished).toBe(true) - }) -}) diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index ac3ff4f03088..6e45077698a5 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -1,5 +1,5 @@ import { mkdtempSync, writeFileSync, mkdirSync, chmodSync, rmSync, readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ @@ -59,9 +59,7 @@ function writeFakeRelay(dir: string): void { // below — so adding one cannot silently fail the completeness probe here. for (const filename of relayArtifactFilenames(false)) { if (filename !== 'relay.js') { - const artifactPath = join(dir, filename) - mkdirSync(dirname(artifactPath), { recursive: true }) - writeFileSync(artifactPath, '') + writeFileSync(join(dir, filename), '') } } writeFileSync( diff --git a/src/main/ssh/ssh-upload-session-lifetime.test.ts b/src/main/ssh/ssh-upload-session-lifetime.test.ts deleted file mode 100644 index adaa13642ff1..000000000000 --- a/src/main/ssh/ssh-upload-session-lifetime.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import type { FileUploadSession } from '../providers/filesystem-provider-contract' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' -import { openTrackedSshUploadSession } from './ssh-upload-session-lifetime' - -const signal = () => new AbortController().signal -const makeSession = (): FileUploadSession => ({ - uploadFile: vi.fn(async () => undefined), - close: vi.fn() -}) - -it('holds an idle session until explicit close and closes the underlying session once', async () => { - const ledger = new SshConnectionWorkLedger() - const underlying = makeSession() - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - const fence = ledger.fenceForReset() - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - session.close() - session.close() - await fence.drain(signal()) - expect(underlying.close).toHaveBeenCalledTimes(1) - await expect(session.uploadFile('source', 'destination')).rejects.toThrow( - 'ssh_connection_work_session_closed' - ) - expect(underlying.uploadFile).not.toHaveBeenCalled() -}) - -it('waits for every concurrent upload after close', async () => { - const ledger = new SshConnectionWorkLedger() - const first = Promise.withResolvers() - const second = Promise.withResolvers() - const underlying = makeSession() - underlying.uploadFile = vi - .fn() - .mockReturnValueOnce(first.promise) - .mockReturnValueOnce(second.promise) - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - const uploadingFirst = session.uploadFile('a', 'b') - const uploadingSecond = session.uploadFile('c', 'd') - const fence = ledger.fenceForReset() - session.close() - first.resolve() - await uploadingFirst - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - second.resolve() - await uploadingSecond - await fence.drain(signal()) - fence.assertDrained() -}) - -it('allows admitted multi-file sessions to continue after fencing but refuses new sessions', async () => { - const ledger = new SshConnectionWorkLedger() - const underlying = makeSession() - underlying.uploadFile = vi.fn(async () => { - await ledger.run(async () => undefined) - }) - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - await session.uploadFile('a', 'b') - const fence = ledger.fenceForReset() - const unopened = vi.fn(async () => makeSession()) - await expect(openTrackedSshUploadSession(ledger, unopened)).rejects.toThrow( - 'ssh_connection_work_admission_closed' - ) - expect(unopened).not.toHaveBeenCalled() - await session.uploadFile('c', 'd', { exclusive: true }) - expect(underlying.uploadFile).toHaveBeenLastCalledWith('c', 'd', { exclusive: true }) - session.close() - await fence.drain(signal()) -}) - -it('retains pending factory admission across the fence and independently tracks physical channels', async () => { - const ledger = new SshConnectionWorkLedger() - const release = Promise.withResolvers() - let channel!: ReturnType - const opening = openTrackedSshUploadSession(ledger, async () => { - await release.promise - await ledger.run(async () => { - channel = ledger.beginChannelOpen() - channel.bind(new EventEmitter()) - }) - return makeSession() - }) - const fence = ledger.fenceForReset() - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - release.resolve() - const session = await opening - session.close() - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - channel.close() - await fence.drain(signal()) -}) - -it('retains failed factories that settle after fencing', async () => { - const ledger = new SshConnectionWorkLedger() - const factory = Promise.withResolvers() - const failure = new Error('factory failed') - const opening = openTrackedSshUploadSession(ledger, () => factory.promise).catch( - (error: unknown) => error - ) - const fence = ledger.fenceForReset() - const draining = fence.drain(signal()).catch((error: unknown) => error) - factory.reject(failure) - expect(await opening).toBe(failure) - expect(await draining).toBe(failure) - expect(fence.assertDrained).toThrow(failure) -}) - -it('wakes reset on upload failure even while the held session remains open', async () => { - const ledger = new SshConnectionWorkLedger() - const upload = Promise.withResolvers() - const underlying = makeSession() - underlying.uploadFile = () => upload.promise - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - const uploading = session.uploadFile('a', 'b').catch((error: unknown) => error) - const fence = ledger.fenceForReset() - const draining = fence.drain(signal()).catch((error: unknown) => error) - const failure = new Error('upload failed') - upload.reject(failure) - expect(await uploading).toBe(failure) - expect(await draining).toBe(failure) - session.close() - expect(fence.assertDrained).toThrow(failure) - await expect(fence.drain(signal())).rejects.toBe(failure) -}) - -it('retains close failure and refuses subsequent uploads or repeated physical close', async () => { - const ledger = new SshConnectionWorkLedger() - const underlying = makeSession() - const failure = new Error('close failed') - underlying.close = vi.fn(() => { - throw failure - }) - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - const fence = ledger.fenceForReset() - expect(session.close).toThrow(failure) - session.close() - expect(underlying.close).toHaveBeenCalledTimes(1) - await expect(session.uploadFile('a', 'b')).rejects.toThrow('ssh_connection_work_session_closed') - await expect(fence.drain(signal())).rejects.toBe(failure) - expect(fence.assertDrained).toThrow(failure) -}) - -it('preserves close uncertainty raised before reset starts', async () => { - const ledger = new SshConnectionWorkLedger() - const underlying = makeSession() - const failure = new Error('close failed before reset') - underlying.close = () => { - throw failure - } - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - expect(session.close).toThrow(failure) - const fence = ledger.fenceForReset() - await expect(fence.drain(signal())).rejects.toBe(failure) - expect(fence.assertDrained).toThrow() -}) - -it('isolates sessions, admission, and failures between connection ledgers', async () => { - const first = new SshConnectionWorkLedger() - const second = new SshConnectionWorkLedger() - const failed = makeSession() - const failure = new Error('first connection failed') - failed.close = () => { - throw failure - } - const firstSession = await openTrackedSshUploadSession(first, async () => failed) - const firstFence = first.fenceForReset() - expect(firstSession.close).toThrow(failure) - const secondSession = await openTrackedSshUploadSession(second, async () => makeSession()) - const secondFence = second.fenceForReset() - await secondSession.uploadFile('a', 'b') - secondSession.close() - await secondFence.drain(signal()) - secondFence.assertDrained() - await expect(firstFence.drain(signal())).rejects.toBe(failure) -}) - -it('does not let stale factory callbacks reuse the held-session admission scope', async () => { - const ledger = new SshConnectionWorkLedger() - const release = Promise.withResolvers() - let stale!: Promise - const session = await openTrackedSshUploadSession(ledger, async () => { - stale = release.promise - .then(() => ledger.run(async () => undefined)) - .catch((error: unknown) => error) - return makeSession() - }) - const fence = ledger.fenceForReset() - release.resolve() - expect(await stale).toMatchObject({ message: 'ssh_connection_work_admission_closed' }) - session.close() - await fence.drain(signal()) -}) diff --git a/src/main/ssh/ssh-upload-session-lifetime.ts b/src/main/ssh/ssh-upload-session-lifetime.ts deleted file mode 100644 index bbbd6f3119c0..000000000000 --- a/src/main/ssh/ssh-upload-session-lifetime.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { FileUploadSession } from '../providers/filesystem-provider-contract' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -export async function openTrackedSshUploadSession( - ledger: SshConnectionWorkLedger, - open: () => Promise -): Promise { - const lifetime = ledger.beginSession() - let session: FileUploadSession - try { - session = await lifetime.run(open) - } catch (error) { - lifetime.close(error) - throw error - } - let closed = false - return { - uploadFile: (...args) => lifetime.run(() => session.uploadFile(...args)), - close: () => { - if (closed) { - return - } - closed = true - try { - session.close() - } catch (error) { - lifetime.close(error) - throw error - } - lifetime.close() - } - } -} diff --git a/src/main/ssh/system-ssh-command.ts b/src/main/ssh/system-ssh-command.ts index 15f099a5f881..eab96245ba53 100644 --- a/src/main/ssh/system-ssh-command.ts +++ b/src/main/ssh/system-ssh-command.ts @@ -96,10 +96,6 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { }, write(chunk, encoding, cb) { proc.stdin!.write(chunk, encoding, cb) - }, - final(cb) { - // EOF must reach the remote reader, not just this local facade. - proc.stdin!.end(cb) } }) const channel = duplex as unknown as SystemSshCommandChannel From 25d7195c906d71a1968bc3f8cd506ef27a28da90 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:04 -0700 Subject: [PATCH 19/26] Revert "feat(profiles): carry markdown frontmatter visibility in project transfers (#16741 T2 P7) (#24405)" This reverts commit 60c93263ccea841249c83b8ec65a6ee9fb0f6049. --- ...-project-session-field-disposition.test.ts | 114 -------- ...ofile-project-session-field-disposition.ts | 5 +- .../profile-project-session-state.ts | 23 -- .../profile-project-session-transfer.ts | 229 +++++++++++++++- .../profile-session-markdown-transfer.ts | 88 ------- .../profile-session-owner-transfer.test.ts | 87 ------- .../profile-session-owner-transfer.ts | 246 ------------------ 7 files changed, 221 insertions(+), 571 deletions(-) delete mode 100644 src/main/orca-profiles/profile-session-markdown-transfer.ts delete mode 100644 src/main/orca-profiles/profile-session-owner-transfer.test.ts delete mode 100644 src/main/orca-profiles/profile-session-owner-transfer.ts diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.test.ts b/src/main/orca-profiles/profile-project-session-field-disposition.test.ts index ce5097671194..daedc20a1119 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.test.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.test.ts @@ -66,120 +66,6 @@ describe('client-hosted rows in the repo-removal and transfer paths', () => { expect(result.clientHostedBrowserPagesByWorktree).toBeUndefined() }) - it('rekeys markdown frontmatter visibility with the open file identity', () => { - const session: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { - [REMOVED_WORKTREE_ID]: [ - { - filePath: '/tmp/worktree-a/README.md', - relativePath: 'README.md', - worktreeId: REMOVED_WORKTREE_ID, - language: 'markdown' - } - ] - }, - markdownFrontmatterVisible: { - '/tmp/worktree-a/README.md': false - } - } - - const result = extractSessionForTransfer(session, REMOVED_REPO_ID, TRANSFER_TARGET_REPO_ID) - - expect(result.markdownFrontmatterVisible).toEqual({ '/tmp/worktree-a/README.md': false }) - }) - - it('omits stale markdown visibility entries that no longer name an open file', () => { - const session: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { - [REMOVED_WORKTREE_ID]: [ - { - filePath: '/tmp/worktree-a/README.md', - relativePath: 'README.md', - worktreeId: REMOVED_WORKTREE_ID, - language: 'markdown' - } - ] - }, - markdownFrontmatterVisible: { - '/tmp/worktree-a/README.md': false, - '/tmp/worktree-a/closed.md': false - } - } - - const result = extractSessionForTransfer(session, REMOVED_REPO_ID, TRANSFER_TARGET_REPO_ID) - - expect(result.markdownFrontmatterVisible).toEqual({ '/tmp/worktree-a/README.md': false }) - }) - - it('prunes markdown visibility for files in a removed repo', () => { - const session = { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { - [REMOVED_WORKTREE_ID]: [ - { - filePath: '/tmp/worktree-a/README.md', - relativePath: 'README.md', - worktreeId: REMOVED_WORKTREE_ID, - language: 'markdown' - } - ], - [RETAINED_WORKTREE_ID]: [ - { - filePath: '/tmp/worktree-b/README.md', - relativePath: 'README.md', - worktreeId: RETAINED_WORKTREE_ID, - language: 'markdown' - } - ] - }, - markdownFrontmatterVisible: { - '/tmp/worktree-a/README.md': false, - '/tmp/worktree-b/README.md': false - } - } - - const result = removeRepoFromWorkspaceSession(session, REMOVED_REPO_ID) - - expect(result.markdownFrontmatterVisible).toEqual({ '/tmp/worktree-b/README.md': false }) - }) - - it('keeps a unified-only terminal layout attached during transfer', () => { - const session = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: {}, - unifiedTabs: { - [REMOVED_WORKTREE_ID]: [ - { - id: 'terminal-tab-1', - entityId: 'terminal-tab-1', - groupId: 'group-1', - worktreeId: REMOVED_WORKTREE_ID, - contentType: 'terminal' as const, - label: 'Terminal', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'terminal-tab-1': { - root: { type: 'leaf' as const, leafId: 'leaf-1' }, - activeLeafId: 'leaf-1', - expandedLeafId: null, - titlesByLeafId: { 'leaf-1': 'shell' } - } - } - } - - const result = extractSessionForTransfer(session, REMOVED_REPO_ID, TRANSFER_TARGET_REPO_ID) - - expect(result.terminalLayoutsByTabId).toEqual(session.terminalLayoutsByTabId) - }) - it('removes the transferred repo rows from the source it left', () => { const source = removeRepoFromWorkspaceSession( sessionWithClientHostedRows(), diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.ts b/src/main/orca-profiles/profile-project-session-field-disposition.ts index 219ef63d8cac..02a7ce77ca66 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.ts @@ -66,10 +66,7 @@ export const WORKSPACE_SESSION_FIELD_DISPOSITION = { }, openFilesByWorktree: { onRepoRemoval: 'prunedByOwnerKey', onTransfer: 'copiedByBespokeRule' }, activeFileIdByWorktree: { onRepoRemoval: 'prunedByOwnerKey', onTransfer: 'copiedByOwnerKey' }, - markdownFrontmatterVisible: { - onRepoRemoval: 'prunedByBespokeRule', - onTransfer: 'copiedByBespokeRule' - }, + markdownFrontmatterVisible: { onRepoRemoval: 'notRepoScoped', onTransfer: 'notTransferred' }, browserTabsByWorktree: { onRepoRemoval: 'prunedByBespokeRule', onTransfer: 'copiedByBespokeRule' diff --git a/src/main/orca-profiles/profile-project-session-state.ts b/src/main/orca-profiles/profile-project-session-state.ts index d17c3b243026..0c1ea243b3e1 100644 --- a/src/main/orca-profiles/profile-project-session-state.ts +++ b/src/main/orca-profiles/profile-project-session-state.ts @@ -3,7 +3,6 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from './profile-project-session-field-disposition' -import { markdownFileIdCandidates } from './profile-session-markdown-transfer' import { isRepoWorktreeId, ownerKeyBelongsToRepo, @@ -59,10 +58,6 @@ export function mergeWorkspaceSessions( } : {}), openFilesByWorktree: { ...base.openFilesByWorktree, ...incoming.openFilesByWorktree }, - markdownFrontmatterVisible: { - ...base.markdownFrontmatterVisible, - ...incoming.markdownFrontmatterVisible - }, browserTabsByWorktree: { ...base.browserTabsByWorktree, ...incoming.browserTabsByWorktree @@ -138,17 +133,6 @@ export function removeRepoFromWorkspaceSession( ): WorkspaceSessionState { const next = structuredClone(session ?? getDefaultWorkspaceSession()) const removedTerminalTabIds = new Set() - const removedMarkdownFileIds = new Set() - for (const [ownerKey, files] of Object.entries(next.openFilesByWorktree ?? {})) { - if (!ownerKeyBelongsToRepo(ownerKey, repoId)) { - continue - } - for (const file of files) { - markdownFileIdCandidates(file.filePath, ownerKey, file.runtimeEnvironmentId).forEach((id) => - removedMarkdownFileIds.add(id) - ) - } - } for (const [ownerKey, tabs] of Object.entries(next.tabsByWorktree)) { if (!ownerKeyBelongsToRepo(ownerKey, repoId)) { continue @@ -178,13 +162,6 @@ export function removeRepoFromWorkspaceSession( const record = next[field] as Record | undefined ;(next as Record)[field] = removeRepoWorktreeRecord(record, repoId) } - if (next.markdownFrontmatterVisible) { - next.markdownFrontmatterVisible = Object.fromEntries( - Object.entries(next.markdownFrontmatterVisible).filter( - ([fileId]) => !removedMarkdownFileIds.has(fileId) - ) - ) - } if (next.terminalSurfaceTombstonesByPaneKey) { next.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( Object.entries(next.terminalSurfaceTombstonesByPaneKey).filter( diff --git a/src/main/orca-profiles/profile-project-session-transfer.ts b/src/main/orca-profiles/profile-project-session-transfer.ts index 39c3b3b6189a..3018f07173c7 100644 --- a/src/main/orca-profiles/profile-project-session-transfer.ts +++ b/src/main/orca-profiles/profile-project-session-transfer.ts @@ -1,10 +1,24 @@ +import { getDefaultWorkspaceSession } from '../../shared/constants' import type { ExecutionHostId } from '../../shared/execution-host' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { rekeyOwnerKey, rekeyWorktreeId } from './profile-project-worktree-identity' +import type { + BrowserPage, + BrowserPageDocLocation, + BrowserWorkspace +} from '../../shared/browser-workspace-types' +import { remapBrowserPageDocLocation } from '../../shared/browser-page-doc-location' +import type { Tab, TabGroup } from '../../shared/tab-types' +import type { TerminalTab } from '../../shared/terminal-tab-types' +import type { + PersistedOpenFile, + WorkspaceSessionState +} from '../../shared/workspace-session-state-types' +import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' +import { SESSION_FIELDS_COPIED_BY_OWNER_KEY } from './profile-project-session-field-disposition' import { - extractSessionOwnersForTransfer, - hasTransferredSessionState -} from './profile-session-owner-transfer' + isRepoWorktreeId, + rekeyOwnerKey, + rekeyWorktreeId +} from './profile-project-worktree-identity' export function extractHostSessionsForTransfer( sessions: Partial> | undefined, @@ -24,13 +38,210 @@ export function extractHostSessionsForTransfer( return next } +function hasTransferredSessionState(session: WorkspaceSessionState): boolean { + return ( + Object.keys(session.tabsByWorktree).length > 0 || + Object.keys(session.openFilesByWorktree ?? {}).length > 0 || + Object.keys(session.browserTabsByWorktree ?? {}).length > 0 || + Object.keys(session.unifiedTabs ?? {}).length > 0 || + Object.keys(session.tabGroups ?? {}).length > 0 || + Object.keys(session.lastVisitedAtByWorktreeId ?? {}).length > 0 || + Object.keys(session.terminalTopologyRevisionByRepoId ?? {}).length > 0 + ) +} + export function extractSessionForTransfer( session: WorkspaceSessionState | undefined, oldRepoId: string, newRepoId: string ): WorkspaceSessionState { - return extractSessionOwnersForTransfer(session, { - mapOwnerKey: (ownerKey) => rekeyOwnerKey(oldRepoId, newRepoId, ownerKey), - mapWorktreeId: (worktreeId) => rekeyWorktreeId(oldRepoId, newRepoId, worktreeId) - }) + const source = session ?? getDefaultWorkspaceSession() + const transferred = getDefaultWorkspaceSession() + const copiedTerminalTabIds = new Set() + const copiedBrowserWorkspaceIds = new Set() + const mapOwnerRecord = ( + record: Record | undefined, + mapValue: (value: T) => T + ): Record => { + const next: Record = {} + for (const [ownerKey, value] of Object.entries(record ?? {})) { + const nextOwnerKey = rekeyOwnerKey(oldRepoId, newRepoId, ownerKey) + if (nextOwnerKey) { + next[nextOwnerKey] = mapValue(value) + } + } + return next + } + transferred.tabsByWorktree = mapOwnerRecord(source.tabsByWorktree, (tabs) => + tabs.map((tab) => { + copiedTerminalTabIds.add(tab.id) + return rekeyTerminalTab(tab, oldRepoId, newRepoId) + }) + ) + transferred.openFilesByWorktree = mapOwnerRecord(source.openFilesByWorktree, (files) => + files.map((file) => rekeyOpenFile(file, oldRepoId, newRepoId)) + ) + transferred.browserTabsByWorktree = mapOwnerRecord(source.browserTabsByWorktree, (tabs) => + tabs.map((tab) => { + copiedBrowserWorkspaceIds.add(tab.id) + return rekeyBrowserWorkspace(tab, oldRepoId, newRepoId) + }) + ) + transferred.browserPagesByWorkspace = copyBrowserPages( + source.browserPagesByWorkspace, + copiedBrowserWorkspaceIds, + oldRepoId, + newRepoId + ) + // Driven by the census so a field cannot be added to the session type and forgotten here. The + // census is also where a field's deliberate non-transfer is recorded -- notably the runtime's + // client-hosted rows, which name a paired device this payload does not carry. + for (const field of SESSION_FIELDS_COPIED_BY_OWNER_KEY) { + const record = source[field] as Record | undefined + ;(transferred as Record)[field] = mapOwnerRecord(record, (value) => + structuredClone(value) + ) + } + transferred.unifiedTabs = mapOwnerRecord(source.unifiedTabs, (tabs) => + tabs.map((tab) => rekeyUnifiedTab(tab, oldRepoId, newRepoId)) + ) + transferred.tabGroups = mapOwnerRecord(source.tabGroups, (groups) => + groups.map((group) => rekeyTabGroup(group, oldRepoId, newRepoId)) + ) + transferred.terminalLayoutsByTabId = {} + for (const tabId of copiedTerminalTabIds) { + const layout = source.terminalLayoutsByTabId[tabId] + if (layout) { + transferred.terminalLayoutsByTabId[tabId] = structuredClone(layout) + } + } + if (source.localOnlyScrollbackByTabId) { + transferred.localOnlyScrollbackByTabId = Object.fromEntries( + Object.entries(source.localOnlyScrollbackByTabId) + .filter(([tabId]) => copiedTerminalTabIds.has(tabId)) + .map(([tabId, buffers]) => [tabId, structuredClone(buffers)]) + ) + } + transferred.terminalPtyIncarnationsByPaneKey = Object.fromEntries( + Object.entries(source.terminalPtyIncarnationsByPaneKey ?? {}).filter(([paneKey]) => { + const separator = paneKey.lastIndexOf(':') + return separator > 0 && copiedTerminalTabIds.has(paneKey.slice(0, separator)) + }) + ) + transferred.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( + Object.entries(source.terminalSurfaceTombstonesByPaneKey ?? {}).flatMap( + ([paneKey, tombstone]) => + isRepoWorktreeId(oldRepoId, tombstone.worktreeId) + ? [ + [ + paneKey, + { + ...structuredClone(tombstone), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tombstone.worktreeId) + } + ] as const + ] + : [] + ) + ) + transferred.activeWorktreeIdsOnShutdown = source.activeWorktreeIdsOnShutdown + ?.filter((worktreeId) => isRepoWorktreeId(oldRepoId, worktreeId)) + .map((worktreeId) => rekeyWorktreeId(oldRepoId, newRepoId, worktreeId)) + if (source.activeWorktreeId && isRepoWorktreeId(oldRepoId, source.activeWorktreeId)) { + transferred.activeWorktreeId = rekeyWorktreeId(oldRepoId, newRepoId, source.activeWorktreeId) + } + const activeScope = source.activeWorkspaceKey + ? parseWorkspaceKey(source.activeWorkspaceKey) + : null + if (activeScope?.type === 'worktree' && isRepoWorktreeId(oldRepoId, activeScope.worktreeId)) { + transferred.activeWorkspaceKey = worktreeWorkspaceKey( + rekeyWorktreeId(oldRepoId, newRepoId, activeScope.worktreeId) + ) + } + return transferred +} + +function rekeyTerminalTab(tab: TerminalTab, oldRepoId: string, newRepoId: string): TerminalTab { + return { + ...structuredClone(tab), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tab.worktreeId) + } +} + +function rekeyOpenFile( + file: PersistedOpenFile, + oldRepoId: string, + newRepoId: string +): PersistedOpenFile { + return { + ...structuredClone(file), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, file.worktreeId) + } +} + +function rekeyBrowserWorkspace( + workspace: BrowserWorkspace, + oldRepoId: string, + newRepoId: string +): BrowserWorkspace { + return { + ...structuredClone(workspace), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, workspace.worktreeId), + ...(workspace.docLocation + ? { docLocation: rekeyBrowserDocLocation(workspace.docLocation, oldRepoId, newRepoId) } + : {}), + // Why: both the session profile and the resolved partition string are + // source-profile-scoped; carrying either across would point the restored + // pane at a partition the target profile's allowlist rejects. + sessionProfileId: null, + sessionPartition: null + } +} + +function rekeyBrowserPage(page: BrowserPage, oldRepoId: string, newRepoId: string): BrowserPage { + return { + ...structuredClone(page), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, page.worktreeId), + ...(page.docLocation + ? { docLocation: rekeyBrowserDocLocation(page.docLocation, oldRepoId, newRepoId) } + : {}) + } +} + +function rekeyBrowserDocLocation( + location: BrowserPageDocLocation, + oldRepoId: string, + newRepoId: string +): BrowserPageDocLocation { + const nextWorktreeId = rekeyWorktreeId(oldRepoId, newRepoId, location.worktreeId) + return remapBrowserPageDocLocation(location, location.worktreeId, nextWorktreeId) +} + +function copyBrowserPages( + pagesByWorkspace: Record | undefined, + workspaceIds: ReadonlySet, + oldRepoId: string, + newRepoId: string +): Record { + const next: Record = {} + for (const [workspaceId, pages] of Object.entries(pagesByWorkspace ?? {})) { + if (workspaceIds.has(workspaceId)) { + next[workspaceId] = pages.map((page) => rekeyBrowserPage(page, oldRepoId, newRepoId)) + } + } + return next +} + +function rekeyUnifiedTab(tab: Tab, oldRepoId: string, newRepoId: string): Tab { + return { + ...structuredClone(tab), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tab.worktreeId) + } +} + +function rekeyTabGroup(group: TabGroup, oldRepoId: string, newRepoId: string): TabGroup { + return { + ...structuredClone(group), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, group.worktreeId) + } } diff --git a/src/main/orca-profiles/profile-session-markdown-transfer.ts b/src/main/orca-profiles/profile-session-markdown-transfer.ts deleted file mode 100644 index c9edf77a9c5a..000000000000 --- a/src/main/orca-profiles/profile-session-markdown-transfer.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' - -type OwnerProjection = { - mapOwnerKey: (ownerKey: string) => string | null - mapWorktreeId: (worktreeId: string) => string -} - -/** Map file-keyed markdown visibility along with the persisted open-file identity it belongs to. */ -export function mapMarkdownFrontmatterVisible( - visibleByFileId: Record | undefined, - filesByOwner: WorkspaceSessionState['openFilesByWorktree'] | undefined, - projection: OwnerProjection -): Record { - const next: Record = {} - const sourceToDestination = buildMarkdownFrontmatterIdMap(filesByOwner, projection) - for (const [sourceFileId, visible] of Object.entries(visibleByFileId ?? {})) { - if (visible) { - // Visible is the hydration default; preserving only hidden overrides avoids carrying stale - // positive entries while retaining the exact rendered result. - continue - } - const destinationId = sourceToDestination.get(sourceFileId) - if (destinationId) { - next[destinationId] = false - } - } - return next -} - -/** - * Builds the file-id mapping used by hydration, marking a source id null when two transferred - * files claim it. An ambiguous visibility override is never guessed at by the migration. - */ -export function buildMarkdownFrontmatterIdMap( - filesByOwner: WorkspaceSessionState['openFilesByWorktree'] | undefined, - projection: OwnerProjection -): ReadonlyMap { - const result = new Map() - for (const [sourceOwnerKey, files] of Object.entries(filesByOwner ?? {})) { - if (!projection.mapOwnerKey(sourceOwnerKey)) { - continue - } - const destinationWorktreeId = projection.mapWorktreeId(sourceOwnerKey) - const sourceWorktreeIds = [sourceOwnerKey] - const separator = sourceOwnerKey.indexOf('|') - if (separator > 0) { - sourceWorktreeIds.push(sourceOwnerKey.slice(separator + 1)) - } - for (const file of files) { - const destinationId = ownedEditorFileId( - file.filePath, - destinationWorktreeId, - file.runtimeEnvironmentId - ) - for (const sourceWorktreeId of sourceWorktreeIds) { - const candidates = markdownFileIdCandidates( - file.filePath, - sourceWorktreeId, - file.runtimeEnvironmentId - ) - for (const sourceId of candidates) { - const mapped = sourceId === file.filePath ? file.filePath : destinationId - const existing = result.get(sourceId) - result.set(sourceId, existing === undefined || existing === mapped ? mapped : null) - } - } - } - } - return result -} - -/** Returns the IDs hydration can assign to a persisted file before any runtime data is loaded. */ -export function markdownFileIdCandidates( - filePath: string, - worktreeId: string, - runtimeEnvironmentId: string | null | undefined -): ReadonlySet { - return new Set([filePath, ownedEditorFileId(filePath, worktreeId, runtimeEnvironmentId)]) -} - -function ownedEditorFileId( - filePath: string, - worktreeId: string, - runtimeEnvironmentId: string | null | undefined -): string { - const runtimeKey = runtimeEnvironmentId?.trim() || 'local' - return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}` -} diff --git a/src/main/orca-profiles/profile-session-owner-transfer.test.ts b/src/main/orca-profiles/profile-session-owner-transfer.test.ts deleted file mode 100644 index ebcbb4720320..000000000000 --- a/src/main/orca-profiles/profile-session-owner-transfer.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { PersistedOpenFile } from '../../shared/workspace-session-state-types' -import { mergeWorkspaceSessions } from './profile-project-session-state' -import { extractSessionForTransfer } from './profile-project-session-transfer' -import { buildMarkdownFrontmatterIdMap } from './profile-session-markdown-transfer' -import { extractSessionOwnersForTransfer } from './profile-session-owner-transfer' - -const SOURCE = 'repo-1::/tmp/a' -const DESTINATION = 'repo-9::/tmp/a' -const editorId = (worktreeId: string, filePath: string, runtime = 'local') => - `editor:${encodeURIComponent(worktreeId)}:${runtime}:${encodeURIComponent(filePath)}` - -function file(filePath: string, worktreeId = SOURCE): PersistedOpenFile { - return { filePath, relativePath: filePath.split('/').at(-1)!, worktreeId, language: 'markdown' } -} - -describe('session owner transfer', () => { - it('moves a hidden override keyed by the owned editor id to the destination worktree', () => { - const session = { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { [SOURCE]: [file('/tmp/a/README.md')] }, - markdownFrontmatterVisible: { - [editorId(SOURCE, '/tmp/a/README.md')]: false, - // Visible is the hydration default, so it is not carried. - '/tmp/a/README.md': true - } - } - - const result = extractSessionForTransfer(session, 'repo-1', 'repo-9') - - expect(result.markdownFrontmatterVisible).toEqual({ - [editorId(DESTINATION, '/tmp/a/README.md')]: false - }) - }) - - it('refuses to guess an override two transferred files both claim', () => { - // Two host-qualified owners of one worktree each claim its unqualified editor id. - const owners = { [`h1|${SOURCE}`]: 'repo-9::/tmp/h1', [`h2|${SOURCE}`]: 'repo-9::/tmp/h2' } - const projection = { - mapOwnerKey: (key: string) => owners[key] ?? null, - mapWorktreeId: (key: string) => owners[key] ?? key - } - const files = { - [`h1|${SOURCE}`]: [file('/tmp/a/x.md')], - [`h2|${SOURCE}`]: [file('/tmp/a/x.md')] - } - - expect( - buildMarkdownFrontmatterIdMap(files, projection).get(editorId(SOURCE, '/tmp/a/x.md')) - ).toBe(null) - const visibility = extractSessionOwnersForTransfer( - { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: files, - markdownFrontmatterVisible: { [editorId(SOURCE, '/tmp/a/x.md')]: false } - }, - projection - ).markdownFrontmatterVisible - - expect(visibility).toEqual({}) - }) - - it('carries a worktree focus key and lets the destination merge keep its own overrides', () => { - const transferred = extractSessionForTransfer( - { - ...getDefaultWorkspaceSession(), - activeWorkspaceKey: `worktree:${SOURCE}`, - openFilesByWorktree: { [SOURCE]: [file('/tmp/a/README.md')] }, - markdownFrontmatterVisible: { '/tmp/a/README.md': false } - }, - 'repo-1', - 'repo-9' - ) - expect(transferred.activeWorkspaceKey).toBe(`worktree:${DESTINATION}`) - - const merged = mergeWorkspaceSessions( - { ...getDefaultWorkspaceSession(), markdownFrontmatterVisible: { '/other.md': false } }, - transferred - ) - - expect(merged.markdownFrontmatterVisible).toEqual({ - '/other.md': false, - '/tmp/a/README.md': false - }) - }) -}) diff --git a/src/main/orca-profiles/profile-session-owner-transfer.ts b/src/main/orca-profiles/profile-session-owner-transfer.ts deleted file mode 100644 index 040cf921f35a..000000000000 --- a/src/main/orca-profiles/profile-session-owner-transfer.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { BrowserPage, BrowserWorkspace } from '../../shared/browser-workspace-types' -import { remapBrowserPageDocLocation } from '../../shared/browser-page-doc-location' -import type { Tab, TabGroup } from '../../shared/tab-types' -import type { TerminalTab } from '../../shared/terminal-tab-types' -import type { - PersistedOpenFile, - WorkspaceSessionState -} from '../../shared/workspace-session-state-types' -import { isWorkspaceKey } from '../../shared/workspace-scope' -import { SESSION_FIELDS_COPIED_BY_OWNER_KEY } from './profile-project-session-field-disposition' -import { mapMarkdownFrontmatterVisible } from './profile-session-markdown-transfer' - -export type SessionOwnerProjection = { - mapOwnerKey: (ownerKey: string) => string | null - mapWorktreeId: (worktreeId: string) => string -} - -export function extractSessionOwnersForTransfer( - session: WorkspaceSessionState | undefined, - projection: SessionOwnerProjection -): WorkspaceSessionState { - const source = session ?? getDefaultWorkspaceSession() - const transferred = getDefaultWorkspaceSession() - const terminalTabIds = new Set() - const browserWorkspaceIds = new Set() - const mapOwnerRecord = ( - record: Record | undefined, - mapValue: (value: T) => T - ): Record => { - const next: Record = {} - for (const [ownerKey, value] of Object.entries(record ?? {})) { - const nextOwnerKey = projection.mapOwnerKey(ownerKey) - if (nextOwnerKey) { - next[nextOwnerKey] = mapValue(value) - } - } - return next - } - transferred.tabsByWorktree = mapOwnerRecord(source.tabsByWorktree, (tabs) => - tabs.map((tab) => { - terminalTabIds.add(tab.id) - return mapTerminalTab(tab, projection) - }) - ) - // Newer sessions may persist a terminal only in the unified tab model while the legacy - // terminal map is absent. Keep its layout and pane metadata attached to the transferred tab. - for (const [ownerKey, tabs] of Object.entries(source.unifiedTabs ?? {})) { - if (!projection.mapOwnerKey(ownerKey)) { - continue - } - for (const tab of tabs) { - if (tab.contentType === 'terminal') { - terminalTabIds.add(tab.id) - terminalTabIds.add(tab.entityId) - } - } - } - transferred.openFilesByWorktree = mapOwnerRecord(source.openFilesByWorktree, (files) => - files.map((file) => mapOpenFile(file, projection)) - ) - transferred.markdownFrontmatterVisible = mapMarkdownFrontmatterVisible( - source.markdownFrontmatterVisible, - source.openFilesByWorktree, - projection - ) - transferred.browserTabsByWorktree = mapOwnerRecord(source.browserTabsByWorktree, (tabs) => - tabs.map((tab) => { - browserWorkspaceIds.add(tab.id) - return mapBrowserWorkspace(tab, projection) - }) - ) - transferred.browserPagesByWorkspace = copyBrowserPages( - source.browserPagesByWorkspace, - browserWorkspaceIds, - projection - ) - for (const field of SESSION_FIELDS_COPIED_BY_OWNER_KEY) { - const record = source[field] as Record | undefined - ;(transferred as Record)[field] = mapOwnerRecord(record, (value) => - structuredClone(value) - ) - } - transferred.unifiedTabs = mapOwnerRecord(source.unifiedTabs, (tabs) => - tabs.map((tab) => mapUnifiedTab(tab, projection)) - ) - transferred.tabGroups = mapOwnerRecord(source.tabGroups, (groups) => - groups.map((group) => mapTabGroup(group, projection)) - ) - transferred.terminalLayoutsByTabId = Object.fromEntries( - [...terminalTabIds].flatMap((tabId) => { - const layout = source.terminalLayoutsByTabId[tabId] - return layout ? [[tabId, structuredClone(layout)] as const] : [] - }) - ) - if (source.localOnlyScrollbackByTabId) { - transferred.localOnlyScrollbackByTabId = Object.fromEntries( - Object.entries(source.localOnlyScrollbackByTabId) - .filter(([tabId]) => terminalTabIds.has(tabId)) - .map(([tabId, buffers]) => [tabId, structuredClone(buffers)]) - ) - } - transferred.terminalPtyIncarnationsByPaneKey = Object.fromEntries( - Object.entries(source.terminalPtyIncarnationsByPaneKey ?? {}).filter(([paneKey]) => - paneBelongsToTabs(paneKey, terminalTabIds) - ) - ) - transferred.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( - Object.entries(source.terminalSurfaceTombstonesByPaneKey ?? {}).flatMap( - ([paneKey, tombstone]) => - projection.mapOwnerKey(tombstone.worktreeId) - ? [ - [ - paneKey, - { - ...structuredClone(tombstone), - worktreeId: projection.mapWorktreeId(tombstone.worktreeId) - } - ] as const - ] - : [] - ) - ) - transferred.activeWorktreeIdsOnShutdown = source.activeWorktreeIdsOnShutdown - ?.filter((worktreeId) => projection.mapOwnerKey(worktreeId) !== null) - .map(projection.mapWorktreeId) - const activeWorktreeId = source.activeWorktreeId - ? projection.mapOwnerKey(source.activeWorktreeId) - : null - if (activeWorktreeId) { - transferred.activeWorktreeId = projection.mapWorktreeId(source.activeWorktreeId!) - } - const activeWorkspaceKey = source.activeWorkspaceKey - ? projection.mapOwnerKey(source.activeWorkspaceKey) - : null - if (activeWorkspaceKey && isWorkspaceKey(activeWorkspaceKey)) { - transferred.activeWorkspaceKey = activeWorkspaceKey - } - return transferred -} - -export function hasTransferredSessionState(session: WorkspaceSessionState): boolean { - return ( - Object.keys(session.tabsByWorktree).length > 0 || - Object.keys(session.openFilesByWorktree ?? {}).length > 0 || - Object.keys(session.markdownFrontmatterVisible ?? {}).length > 0 || - Object.keys(session.browserTabsByWorktree ?? {}).length > 0 || - Object.keys(session.browserPagesByWorkspace ?? {}).length > 0 || - Object.keys(session.unifiedTabs ?? {}).length > 0 || - Object.keys(session.tabGroups ?? {}).length > 0 || - Object.keys(session.terminalLayoutsByTabId ?? {}).length > 0 || - SESSION_FIELDS_COPIED_BY_OWNER_KEY.some( - (field) => Object.keys(session[field] ?? {}).length > 0 - ) || - Object.keys(session.terminalSurfaceTombstonesByPaneKey ?? {}).length > 0 || - Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).length > 0 || - Boolean(session.activeWorktreeId || session.activeWorkspaceKey) || - (session.activeWorktreeIdsOnShutdown?.length ?? 0) > 0 - ) -} - -function mapTerminalTab(tab: TerminalTab, projection: SessionOwnerProjection): TerminalTab { - const { pendingActivationSpawn: _pendingActivationSpawn, ...persisted } = tab - return { - ...structuredClone(persisted), - worktreeId: projection.mapWorktreeId(tab.worktreeId) - } -} - -function mapOpenFile( - file: PersistedOpenFile, - projection: SessionOwnerProjection -): PersistedOpenFile { - return { - ...structuredClone(file), - worktreeId: projection.mapWorktreeId(file.worktreeId) - } -} - -function mapBrowserWorkspace( - workspace: BrowserWorkspace, - projection: SessionOwnerProjection -): BrowserWorkspace { - return { - ...structuredClone(workspace), - worktreeId: projection.mapWorktreeId(workspace.worktreeId), - ...(workspace.docLocation - ? { - docLocation: remapBrowserPageDocLocation( - workspace.docLocation, - workspace.docLocation.worktreeId, - projection.mapWorktreeId(workspace.docLocation.worktreeId) - ) - } - : {}), - // Why: the browser session profile and partition are source-profile-scoped. - sessionProfileId: null, - sessionPartition: null - } -} - -function mapBrowserPage(page: BrowserPage, projection: SessionOwnerProjection): BrowserPage { - return { - ...structuredClone(page), - worktreeId: projection.mapWorktreeId(page.worktreeId), - ...(page.docLocation - ? { - docLocation: remapBrowserPageDocLocation( - page.docLocation, - page.docLocation.worktreeId, - projection.mapWorktreeId(page.docLocation.worktreeId) - ) - } - : {}) - } -} - -function copyBrowserPages( - pagesByWorkspace: Record | undefined, - workspaceIds: ReadonlySet, - projection: SessionOwnerProjection -): Record { - const next: Record = {} - for (const [workspaceId, pages] of Object.entries(pagesByWorkspace ?? {})) { - if (workspaceIds.has(workspaceId)) { - next[workspaceId] = pages.map((page) => mapBrowserPage(page, projection)) - } - } - return next -} - -function mapUnifiedTab(tab: Tab, projection: SessionOwnerProjection): Tab { - return { - ...structuredClone(tab), - worktreeId: projection.mapWorktreeId(tab.worktreeId) - } -} - -function mapTabGroup(group: TabGroup, projection: SessionOwnerProjection): TabGroup { - return { ...structuredClone(group), worktreeId: projection.mapWorktreeId(group.worktreeId) } -} - -function paneBelongsToTabs(paneKey: string, tabIds: ReadonlySet): boolean { - const separator = paneKey.lastIndexOf(':') - return separator > 0 && tabIds.has(paneKey.slice(0, separator)) -} From ba84e32869cfc71571462faadeaa33ebd6175096 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:04 -0700 Subject: [PATCH 20/26] Revert "fix(runtime): project the PTY incarnation onto mobile session tabs (#24413)" This reverts commit 99e030357226f76a6aed7514b81a951a10a7de6b. --- ...ile-session-incarnation-projection.test.ts | 65 ------------------- .../runtime-mobile-session-projection.ts | 19 +++--- 2 files changed, 10 insertions(+), 74 deletions(-) delete mode 100644 src/main/runtime/runtime-mobile-session-incarnation-projection.test.ts diff --git a/src/main/runtime/runtime-mobile-session-incarnation-projection.test.ts b/src/main/runtime/runtime-mobile-session-incarnation-projection.test.ts deleted file mode 100644 index 80f30b64342b..000000000000 --- a/src/main/runtime/runtime-mobile-session-incarnation-projection.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { projectRuntimeMobileSessionTabs } from './runtime-mobile-session-projection' -import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract' -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' -import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' - -it.each(['pty', 'leaf', 'unknown', 'disconnected'] as const)( - 'publishes only the handle-owning incarnation for %s', - (kind) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: projection reads only ptyId, connected and incarnationId from the record. - const pty = { - ptyId: 'live-pty', - connected: kind !== 'disconnected', - incarnationId: kind === 'unknown' ? null : 'live-incarnation' - } as RuntimePtyWorktreeRecord - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every host member this projection path calls. - const host = { - tabs: new Map(), - leaves: new Map(kind === 'leaf' ? [['leaf', { ptyId: pty.ptyId, connected: true }]] : []), - ptysById: new Map([[pty.ptyId, pty]]), - getLiveBrowserTabs: () => new Map(), - getProviderSessionRows: () => [], - getProviderSessionSnapshot: () => [], - getLeafKey: () => 'leaf', - findPty: () => pty, - getRetainedStatus: () => null, - getTrackedTitle: () => null, - issuePtyHandle: vi.fn(() => 'handle'), - recordPty: vi.fn(() => pty), - buildPtyStatus: () => ({}), - sanitizeGroups: () => [], - pruneGroupLayout: () => null, - collectTabIds: () => new Set() - } as unknown as RuntimeMobileSessionProjectionHost - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: one terminal tab is the only snapshot field this projection path reads. - const snapshot = { - worktree: 'workspace', - publicationEpoch: 'headless:epoch', - tabs: [ - { - type: 'terminal', - id: 'tab::leaf', - parentTabId: 'tab', - leafId: 'leaf', - ptyId: 'stale-pty', - incarnationId: 'stale-incarnation', - title: 'Shell', - isActive: true - } - ] - } as RuntimeMobileSessionTabsSnapshot - - const tab = projectRuntimeMobileSessionTabs(snapshot, host).tabs[0] - - if (kind === 'unknown' || kind === 'disconnected') { - expect(tab).not.toHaveProperty('incarnationId') - } else { - expect(tab).toHaveProperty('incarnationId', 'live-incarnation') - } - expect(tab).toHaveProperty('status', kind === 'disconnected' ? 'pending-handle' : 'ready') - if (kind !== 'disconnected') { - expect(host.issuePtyHandle).toHaveBeenCalledWith(pty) - } - } -) diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index 969a2946c9a2..db1ef0619cab 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -247,14 +247,17 @@ export function projectRuntimeMobileSessionTabs( } : null // Why: web/mobile clients hold handles across renderer graph syncs; leaf handles are epoch-bound but PTY handles stay streamable. - const terminalPty = liveLeafPtyId - ? host.recordPty(liveLeafPtyId, snapshot.worktree, { - tabId: tab.parentTabId, - paneKey, - connected: true - }) + const terminalHandle = liveLeafPtyId + ? host.issuePtyHandle( + host.recordPty(liveLeafPtyId, snapshot.worktree, { + tabId: tab.parentTabId, + paneKey, + connected: true + }) + ) : livePty - const terminalHandle = terminalPty ? host.issuePtyHandle(terminalPty) : null + ? host.issuePtyHandle(livePty) + : null const projectedAgentStatus = agentStatus ?? host.buildPtyStatus( @@ -287,8 +290,6 @@ export function projectRuntimeMobileSessionTabs( leafId: tab.leafId, title, ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), - // Bind identity to the handle's live owner, never a stale persisted surface. - ...(terminalPty?.incarnationId ? { incarnationId: terminalPty.incarnationId } : {}), ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), ...(launchAgent ? { launchAgent } : {}), ...clientAgentStatus, From adf1f680b475c9268866765f465900da634cb3ac Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:04 -0700 Subject: [PATCH 21/26] Revert "feat(daemon): tag daemon stream data with the PTY incarnation id (#16741 T2 P4a) (#24402)" This reverts commit 817af768b01b296800e5850654493491e5aadfe6. --- .../daemon/daemon-stream-data-batcher.test.ts | 14 ----- src/main/daemon/daemon-stream-data-batcher.ts | 3 +- src/main/daemon/daemon-stream-data-entry.ts | 11 +--- .../daemon/daemon-stream-data-split.test.ts | 41 +------------ src/main/daemon/daemon-stream-data-split.ts | 58 +++++------------- src/main/daemon/daemon-stream-events.ts | 1 - .../daemon/daemon-stream-incarnation.test.ts | 59 ------------------- .../daemon/daemon-stream-keep-tail-drop.ts | 2 - src/main/daemon/daemon-terminal-admission.ts | 41 +++++-------- src/main/daemon/session-output-pipeline.ts | 3 - src/main/daemon/session-output-plane.ts | 35 ++--------- src/main/daemon/session.ts | 20 ++++--- .../daemon/terminal-host-create-contract.ts | 7 --- 13 files changed, 53 insertions(+), 242 deletions(-) delete mode 100644 src/main/daemon/daemon-stream-incarnation.test.ts diff --git a/src/main/daemon/daemon-stream-data-batcher.test.ts b/src/main/daemon/daemon-stream-data-batcher.test.ts index 7d6f825b3f73..f10715e8711b 100644 --- a/src/main/daemon/daemon-stream-data-batcher.test.ts +++ b/src/main/daemon/daemon-stream-data-batcher.test.ts @@ -118,20 +118,6 @@ describe('DaemonStreamDataBatcher', () => { }) }) - it('preserves PTY incarnation identity through stream serialization', () => { - const { batcher, streamSocket } = createBatcher() - - batcher.enqueue('client-1', 'session-1', 'output', { - flushImmediately: true, - incarnationId: 'incarnation-1' - }) - - expect(JSON.parse(String(streamSocket.write.mock.calls[0]?.[0]))).toMatchObject({ - event: 'data', - payload: { data: 'output', incarnationId: 'incarnation-1' } - }) - }) - it('keeps large pending output batched even when an interactive redraw follows', () => { vi.useFakeTimers() try { diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts index 202d59c40090..a562a60ee9d5 100644 --- a/src/main/daemon/daemon-stream-data-batcher.ts +++ b/src/main/daemon/daemon-stream-data-batcher.ts @@ -246,8 +246,7 @@ export class DaemonStreamDataBatcher { this.maxLineBytes, sliceSequenceChars, entry.seq, - entry.transformed, - entry.incarnationId + entry.transformed ) } this.updateBackpressure(clientId, batch) diff --git a/src/main/daemon/daemon-stream-data-entry.ts b/src/main/daemon/daemon-stream-data-entry.ts index e4159aaa88af..017d21fde8c6 100644 --- a/src/main/daemon/daemon-stream-data-entry.ts +++ b/src/main/daemon/daemon-stream-data-entry.ts @@ -2,7 +2,6 @@ import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' import { writeStreamDataEvents } from './daemon-stream-data-split' import { encodeNdjson } from './ndjson' import { accountDaemonStreamEntry } from './daemon-stream-entry-accounting' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' export type DaemonStreamEnqueueOptions = { flushImmediately?: boolean @@ -10,7 +9,6 @@ export type DaemonStreamEnqueueOptions = { rawLength?: number transformed?: boolean seq?: number - incarnationId?: PtyIncarnationId } export function appendDaemonStreamData( @@ -25,8 +23,7 @@ export function appendDaemonStreamData( last?.sessionId === sessionId && !last.control && !last.transformed && - options.transformed !== true && - last.incarnationId === options.incarnationId + options.transformed !== true ) { last.data += data const rawLengthBefore = last.sequenceChars ?? last.data.length - data.length @@ -42,8 +39,7 @@ export function appendDaemonStreamData( ? {} : { sequenceChars: options.rawLength }), ...(options.transformed ? { transformed: true } : {}), - ...(options.seq === undefined ? {} : { seq: options.seq }), - ...(options.incarnationId === undefined ? {} : { incarnationId: options.incarnationId }) + ...(options.seq === undefined ? {} : { seq: options.seq }) }) ) } @@ -95,8 +91,7 @@ export function flushDaemonStreamSession( maxLineBytes, entry.sequenceChars ?? entry.data.length, entry.seq, - entry.transformed, - entry.incarnationId + entry.transformed ) } } diff --git a/src/main/daemon/daemon-stream-data-split.test.ts b/src/main/daemon/daemon-stream-data-split.test.ts index 2fe9983252ab..48cf8cbc80ea 100644 --- a/src/main/daemon/daemon-stream-data-split.test.ts +++ b/src/main/daemon/daemon-stream-data-split.test.ts @@ -18,8 +18,7 @@ function write( rawLength = data.length, seq?: number, transformed = false, - sessionId = 'session-1', - incarnationId?: string + sessionId = 'session-1' ): string[] { const lines: string[] = [] const socket: Pick = { @@ -28,16 +27,7 @@ function write( return true }) } - writeStreamDataEvents( - socket, - sessionId, - data, - maxLineBytes, - rawLength, - seq, - transformed, - incarnationId - ) + writeStreamDataEvents(socket, sessionId, data, maxLineBytes, rawLength, seq, transformed) return lines } @@ -94,15 +84,6 @@ describe('writeStreamDataEvents serialization budget', () => { expect(encodeNdjson).toHaveBeenCalledTimes(1) }) - it('retains incarnation metadata in the single-encode fast path', () => { - const line = encodeStreamDataEvent('session-1', 'é🐙', undefined, undefined, false, 'epoch-1') - vi.mocked(encodeNdjson).mockClear() - expect( - write('é🐙', Buffer.byteLength(line), 3, undefined, false, 'session-1', 'epoch-1') - ).toEqual([line]) - expect(encodeNdjson).toHaveBeenCalledTimes(1) - }) - it('does not add a duplicate full-data sizing probe to oversized writes', () => { const data = '🐙\x1b[0m'.repeat(100) const expected = previousWrites(data, 160) @@ -121,24 +102,6 @@ describe('writeStreamDataEvents serialization budget', () => { }) describe('writeStreamDataEvents wire parity', () => { - it.each([undefined, 9000])('budgets incarnation metadata on oversized writes (seq=%s)', (seq) => { - const data = '🐙é中\x1b[0m"\\\n'.repeat(100) - const incarnationId = 'epoch-'.repeat(16) - const lines = write(data, 384, data.length, seq, false, 'session-1', incarnationId) - expect(lines.length).toBeGreaterThan(1) - let consumed = 0 - const chunks = lines.map((line) => { - expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(384) - const { payload } = JSON.parse(line) - expect(payload.incarnationId).toBe(incarnationId) - expect(typeof payload.data).toBe('string') - consumed += payload.data.length - expect(payload.seq).toBe(seq === undefined ? undefined : seq - data.length + consumed) - return payload.data - }) - expect(chunks.join('')).toBe(data) - }) - it('preserves exact frames, chunk boundaries and metadata across payloads and caps', () => { const payloads = [ '', diff --git a/src/main/daemon/daemon-stream-data-split.ts b/src/main/daemon/daemon-stream-data-split.ts index b644d971e4bb..c31c4437b840 100644 --- a/src/main/daemon/daemon-stream-data-split.ts +++ b/src/main/daemon/daemon-stream-data-split.ts @@ -5,15 +5,13 @@ */ import { resolveSynchronizedOutputSafeSplit } from '../../shared/terminal-synchronized-output-scan' import { encodeNdjson } from './ndjson' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' export function encodeStreamDataEvent( sessionId: string, data: string, rawLength?: number, seq?: number, - transformed?: boolean, - incarnationId?: PtyIncarnationId + transformed?: boolean ): string { return encodeNdjson({ type: 'event', @@ -21,7 +19,6 @@ export function encodeStreamDataEvent( sessionId, payload: { data, - ...(incarnationId === undefined ? {} : { incarnationId }), ...(seq === undefined ? {} : { seq }), ...(rawLength === undefined ? {} : { rawLength }), ...(rawLength === undefined ? {} : { sequenceChars: rawLength }), @@ -30,16 +27,8 @@ export function encodeStreamDataEvent( }) } -function streamDataEventLineBytes( - sessionId: string, - data: string, - rawLength?: number, - incarnationId?: PtyIncarnationId -): number { - return Buffer.byteLength( - encodeStreamDataEvent(sessionId, data, rawLength, undefined, false, incarnationId), - 'utf8' - ) +function streamDataEventLineBytes(sessionId: string, data: string, rawLength?: number): number { + return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, rawLength), 'utf8') } function isHighSurrogate(value: number): boolean { @@ -90,28 +79,20 @@ export function splitStreamDataForNdjson( sessionId: string, data: string, maxLineBytes: number, - sequenceChars?: number, - incarnationId?: PtyIncarnationId + sequenceChars?: number ): string[] { - if (streamDataEventLineBytes(sessionId, data, sequenceChars, incarnationId) <= maxLineBytes) { + if (streamDataEventLineBytes(sessionId, data, sequenceChars) <= maxLineBytes) { return [data] } - return splitOversizedStreamDataForNdjson( - sessionId, - data, - maxLineBytes, - sequenceChars, - incarnationId - ) + return splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes, sequenceChars) } function splitOversizedStreamDataForNdjson( sessionId: string, data: string, maxLineBytes: number, - sequenceChars?: number, - incarnationId?: PtyIncarnationId + sequenceChars?: number ): string[] { const chunks: string[] = [] let start = 0 @@ -129,8 +110,7 @@ function splitOversizedStreamDataForNdjson( } if ( - streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars, incarnationId) <= - maxLineBytes + streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars) <= maxLineBytes ) { best = mid low = rawMid + 1 @@ -154,36 +134,28 @@ export function writeStreamDataEvents( maxLineBytes: number, rawLength = data.length, seq?: number, - transformed = false, - incarnationId?: PtyIncarnationId + transformed = false ): void { const explicitRawLength = rawLength === data.length ? undefined : rawLength if (transformed) { - streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true, incarnationId)) + streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true)) return } const carriesMetadata = explicitRawLength !== undefined || seq !== undefined let chunks: string[] if (!carriesMetadata) { - const line = encodeStreamDataEvent(sessionId, data, undefined, undefined, false, incarnationId) + const line = encodeStreamDataEvent(sessionId, data) if (Buffer.byteLength(line, 'utf8') <= maxLineBytes) { streamSocket.write(line) return } - chunks = splitOversizedStreamDataForNdjson( - sessionId, - data, - maxLineBytes, - undefined, - incarnationId - ) + chunks = splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes) } else { chunks = splitStreamDataForNdjson( sessionId, data, Math.max(1, maxLineBytes - 96), - explicitRawLength, - incarnationId + explicitRawLength ) } let consumed = 0 @@ -191,8 +163,6 @@ export function writeStreamDataEvents( consumed += chunk.length const chunkEndSeq = seq === undefined ? undefined : seq - (data.length - consumed) const chunkRawLength = explicitRawLength === 0 ? 0 : carriesMetadata ? chunk.length : undefined - streamSocket.write( - encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq, false, incarnationId) - ) + streamSocket.write(encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq)) } } diff --git a/src/main/daemon/daemon-stream-events.ts b/src/main/daemon/daemon-stream-events.ts index ede46bff1331..55e9f0533168 100644 --- a/src/main/daemon/daemon-stream-events.ts +++ b/src/main/daemon/daemon-stream-events.ts @@ -9,7 +9,6 @@ export type DataEvent = { sessionId: string payload: { data: string - incarnationId?: PtyIncarnationId seq?: number rawLength?: number transformed?: boolean diff --git a/src/main/daemon/daemon-stream-incarnation.test.ts b/src/main/daemon/daemon-stream-incarnation.test.ts deleted file mode 100644 index e52a49c444ac..000000000000 --- a/src/main/daemon/daemon-stream-incarnation.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { appendDaemonStreamData, flushDaemonStreamSession } from './daemon-stream-data-entry' -import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' -import { SessionOutputPlane } from './session-output-plane' - -function batch(): PendingStreamDataBatch { - return { - timer: null, - queue: [], - queuedChars: 0, - queuedCharsBySession: new Map(), - queuedMetadataBytesBySession: new Map(), - droppableQueuedSessionIds: new Set() - } -} - -describe('daemon stream incarnation ids', () => { - it('never merges output from two incarnations into one queued entry', () => { - const pending = batch() - appendDaemonStreamData(pending, 'session-1', 'old', { incarnationId: 'incarnation-1' }) - appendDaemonStreamData(pending, 'session-1', 'more', { incarnationId: 'incarnation-1' }) - appendDaemonStreamData(pending, 'session-1', 'new', { incarnationId: 'incarnation-2' }) - - expect(pending.queue.map(({ data, incarnationId }) => ({ data, incarnationId }))).toEqual([ - { data: 'oldmore', incarnationId: 'incarnation-1' }, - { data: 'new', incarnationId: 'incarnation-2' } - ]) - }) - - it('writes the id on per-session flushes and omits it when absent', () => { - const pending = batch() - appendDaemonStreamData(pending, 'session-1', 'tagged', { incarnationId: 'incarnation-1' }) - appendDaemonStreamData(pending, 'session-1', 'legacy', {}) - const lines: string[] = [] - flushDaemonStreamSession(pending, 'session-1', 64 * 1024, (line) => lines.push(line)) - - const payloads = lines.map((line) => JSON.parse(line).payload) - expect(payloads).toEqual([ - { data: 'tagged', incarnationId: 'incarnation-1' }, - { data: 'legacy' } - ]) - }) - - it('hands the session incarnation only to identity-aware clients', () => { - const plane = new SessionOutputPlane({ cols: 80, rows: 24, incarnationId: 'incarnation-1' }) - const legacy = vi.fn() - const aware = vi.fn() - const unused = vi.fn() - plane.attachClient({ onData: legacy, onExit: vi.fn() }) - plane.attachClient({ onData: unused, onDataWithIncarnation: aware, onExit: vi.fn() }) - - plane.emit({ data: 'hi', rawStartSeq: 0, rawEndSeq: 2, transformed: false }) - - expect(legacy).toHaveBeenCalledWith('hi') - expect(aware).toHaveBeenCalledWith('hi', undefined, undefined, undefined, 'incarnation-1') - expect(unused).not.toHaveBeenCalled() - plane.disposeEmulator() - }) -}) diff --git a/src/main/daemon/daemon-stream-keep-tail-drop.ts b/src/main/daemon/daemon-stream-keep-tail-drop.ts index 5fcfeed09a0d..7bbd20047fe2 100644 --- a/src/main/daemon/daemon-stream-keep-tail-drop.ts +++ b/src/main/daemon/daemon-stream-keep-tail-drop.ts @@ -14,7 +14,6 @@ import { accountDaemonStreamEntry, releaseDaemonStreamEntry } from './daemon-stream-entry-accounting' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' // A control entry carries a whole pre-shaped stream event (background marker, // data gap, transient fact) that must ride at its exact position in the @@ -28,7 +27,6 @@ export type StreamQueueEntry = { sequenceChars?: number seq?: number transformed?: boolean - incarnationId?: PtyIncarnationId control?: DaemonEvent retainedBytes?: number } diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index 984cbc87dec4..f02cbdb9205c 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -173,32 +173,23 @@ export class DaemonTerminalAdmission { clientId: string, sessionId: () => string ): CreateOrAttachOptions['streamClient'] { - const onData = ( - data: string, - rawLength = data.length, - transformed = false, - seq?: number, - incarnationId?: string - ): void => { - const routedSessionId = sessionId() - this.options.transientFactRelay.onSessionData(routedSessionId, data) - const lastInputAt = this.options.attachments.lastInputAt(routedSessionId) - const isInteractiveOutput = - data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS && - lastInputAt !== undefined && - performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS - this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, { - flushImmediately: isInteractiveOutput, - flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS, - rawLength, - transformed, - seq, - ...(incarnationId === undefined ? {} : { incarnationId }) - }) - } return { - onData, - onDataWithIncarnation: onData, + onData: (data, rawLength = data.length, transformed = false, seq) => { + const routedSessionId = sessionId() + this.options.transientFactRelay.onSessionData(routedSessionId, data) + const lastInputAt = this.options.attachments.lastInputAt(routedSessionId) + const isInteractiveOutput = + data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS && + lastInputAt !== undefined && + performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS + this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, { + flushImmediately: isInteractiveOutput, + flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS, + rawLength, + transformed, + seq + }) + }, onExit: (code, incarnationId, cause) => { const routedSessionId = sessionId() this.options.log.log('session-exited', { diff --git a/src/main/daemon/session-output-pipeline.ts b/src/main/daemon/session-output-pipeline.ts index 819b9c722593..f67d2e15b927 100644 --- a/src/main/daemon/session-output-pipeline.ts +++ b/src/main/daemon/session-output-pipeline.ts @@ -1,7 +1,6 @@ import { SessionOutputPlane } from './session-output-plane' import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import type { SubprocessHandle } from './session-subprocess-handle' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' /** The session's ordered output pipeline: the recovery barrier feeding the * output plane. Built together because the barrier's owner is what the @@ -12,7 +11,6 @@ export function createSessionOutputPipeline(opts: { scrollback?: number | undefined wslDistro?: string | undefined historySeedChunks?: readonly string[] | undefined - incarnationId?: PtyIncarnationId | undefined subprocess: SubprocessHandle isAlive: () => boolean }): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } { @@ -24,7 +22,6 @@ export function createSessionOutputPipeline(opts: { scrollback: opts.scrollback, wslDistro: opts.wslDistro, historySeedChunks: opts.historySeedChunks, - incarnationId: opts.incarnationId, getTerminalOwner: () => barrier?.getOwner() }) const recoveryBarrier = new TerminalShellRecoveryBarrier({ diff --git a/src/main/daemon/session-output-plane.ts b/src/main/daemon/session-output-plane.ts index d994fdf4b484..04b3f773bda3 100644 --- a/src/main/daemon/session-output-plane.ts +++ b/src/main/daemon/session-output-plane.ts @@ -5,7 +5,6 @@ import { normalizePtySize } from './daemon-pty-size' import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' import type { PendingOutputRecord, TakePendingOutputResult, TerminalSnapshot } from './types' import type { TerminalOwner } from '../../shared/terminal-owner' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { SubprocessHandle } from './session-subprocess-handle' import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint' @@ -16,14 +15,6 @@ const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024 export type AttachedClient = { token: symbol onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void - /** Identity-bearing callback used by mutation-aware consumers; legacy clients keep the old shape. */ - onDataWithIncarnation?: ( - data: string, - rawLength: number | undefined, - transformed: boolean | undefined, - seq: number | undefined, - incarnationId: PtyIncarnationId - ) => void onExit: (code: number, incarnationId: string, cause?: TerminalExitCause) => void } @@ -33,7 +24,6 @@ export type SessionOutputPlaneOptions = { scrollback?: number | undefined wslDistro?: string | undefined historySeedChunks?: readonly string[] | undefined - incarnationId?: PtyIncarnationId | undefined /** Read from the recovery barrier at snapshot time; the barrier scans bytes * before this plane receives them, so its owner never lags the emulator. */ getTerminalOwner?: (() => TerminalOwner | undefined) | undefined @@ -45,7 +35,6 @@ export class SessionOutputPlane { readonly historySeeded: boolean | undefined private readonly emulator: HeadlessEmulator private readonly readTerminalOwner: (() => TerminalOwner | undefined) | undefined - private readonly incarnationId: PtyIncarnationId | undefined private attachedClients: AttachedClient[] = [] private pendingOutputRecords: PendingOutputRecord[] = [] private pendingOutputBytes = 0 @@ -75,7 +64,6 @@ export class SessionOutputPlane { ? undefined : opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk)) this.readTerminalOwner = opts.getTerminalOwner - this.incarnationId = opts.incarnationId } get responderParser(): HeadlessEmulator['responderParser'] { @@ -209,7 +197,9 @@ export class SessionOutputPlane { return } this.record({ kind: 'output', data: pending }) - this.broadcastData(pending, 0, true, this._outputSequence) + for (const client of this.attachedClients) { + client.onData(pending, 0, true, this._outputSequence) + } } emit(emission: PtyIngressEmission): void { @@ -226,24 +216,9 @@ export class SessionOutputPlane { } // Broadcast to attached clients - if (emission.transformed || rawLength !== data.length) { - this.broadcastData(data, rawLength, true, this._outputSequence) - } else { - this.broadcastData(data) - } - } - - private broadcastData( - data: string, - rawLength?: number, - transformed?: boolean, - seq?: number - ): void { for (const client of this.attachedClients) { - if (client.onDataWithIncarnation && this.incarnationId) { - client.onDataWithIncarnation(data, rawLength, transformed, seq, this.incarnationId) - } else if (transformed || rawLength !== undefined || seq !== undefined) { - client.onData(data, rawLength, transformed, seq) + if (emission.transformed || rawLength !== data.length) { + client.onData(data, rawLength, true, this._outputSequence) } else { client.onData(data) } diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7c63dbd1d3f2..7d1136652924 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -12,7 +12,12 @@ import type { TuiAgent } from '../../shared/tui-agent' import { randomUUID } from 'node:crypto' import { PtyStartupIngress } from '../../shared/pty-startup-ingress' -import type * as SessionProtocol from './types' +import type { + SessionState, + ShellReadyState, + TakePendingOutputResult, + TerminalSnapshot +} from './types' import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' @@ -23,7 +28,7 @@ export class Session { readonly launchAgent: TuiAgent | null readonly wslDistro: string | null readonly processNameIsSpawnFile: boolean - private _state: SessionProtocol.SessionState = 'running' + private _state: SessionState = 'running' private _exitCode: number | null = null private _disposed = false private subprocess: SubprocessHandle @@ -50,8 +55,7 @@ export class Session { wslDistro: opts.wslDistro, historySeedChunks: opts.historySeedChunks, subprocess: this.subprocess, - isAlive: () => !this._disposed && this._state !== 'exited', - incarnationId: this.incarnationId + isAlive: () => !this._disposed && this._state !== 'exited' }) this.output = pipeline.output this.recoveryBarrier = pipeline.recoveryBarrier @@ -91,11 +95,11 @@ export class Session { this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause)) } - get state(): SessionProtocol.SessionState { + get state(): SessionState { return this._state } - get shellState(): SessionProtocol.ShellReadyState { + get shellState(): ShellReadyState { return this.shellReady.state } @@ -212,7 +216,7 @@ export class Session { this.producerPause.release({ resume: true }) } - getSnapshot(opts: { scrollbackRows?: number } = {}): SessionProtocol.TerminalSnapshot | null { + getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { this.startupIngress.snapshotBarrier() return this.output.getSnapshot(opts) } @@ -228,7 +232,7 @@ export class Session { takePendingOutput( includeSnapshot: boolean, opts: { teardownSnapshot?: boolean } = {} - ): SessionProtocol.TakePendingOutputResult | null { + ): TakePendingOutputResult | null { if (this._disposed) { return null } diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 654df23af816..9f6e6fbec1b1 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -37,13 +37,6 @@ export type CreateOrAttachOptions = { } streamClient: { onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void - onDataWithIncarnation?: ( - data: string, - rawLength: number | undefined, - transformed: boolean | undefined, - seq: number | undefined, - incarnationId: PtyIncarnationId - ) => void onExit: (code: number, incarnationId: PtyIncarnationId, cause?: TerminalExitCause) => void } /** Lets the daemon route output under the adopted owner's canonical id before From c5e717daa63e929f7f343e34f5aa6c2567d13dca Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:05 -0700 Subject: [PATCH 22/26] Revert "feat(ssh): port the SSH connection work ledger and transport close ledger (#16741 T2) (#24210)" This reverts commit c9918931c8e6fa23e311814eda9ca63589fe45a8. --- .../ssh-connection-channel-lifetime.test.ts | 65 ----- .../ssh/ssh-connection-channel-lifetime.ts | 60 ---- .../ssh/ssh-connection-work-ledger.test.ts | 256 ------------------ src/main/ssh/ssh-connection-work-ledger.ts | 208 -------------- .../ssh/ssh-forward-socket-admission.test.ts | 35 --- .../ssh/ssh-transport-close-ledger.test.ts | 69 ----- src/main/ssh/ssh-transport-close-ledger.ts | 39 --- .../ssh-transport-close-observation.test.ts | 33 --- .../ssh/ssh-transport-close-observation.ts | 25 -- 9 files changed, 790 deletions(-) delete mode 100644 src/main/ssh/ssh-connection-channel-lifetime.test.ts delete mode 100644 src/main/ssh/ssh-connection-channel-lifetime.ts delete mode 100644 src/main/ssh/ssh-connection-work-ledger.test.ts delete mode 100644 src/main/ssh/ssh-connection-work-ledger.ts delete mode 100644 src/main/ssh/ssh-forward-socket-admission.test.ts delete mode 100644 src/main/ssh/ssh-transport-close-ledger.test.ts delete mode 100644 src/main/ssh/ssh-transport-close-ledger.ts delete mode 100644 src/main/ssh/ssh-transport-close-observation.test.ts delete mode 100644 src/main/ssh/ssh-transport-close-observation.ts diff --git a/src/main/ssh/ssh-connection-channel-lifetime.test.ts b/src/main/ssh/ssh-connection-channel-lifetime.test.ts deleted file mode 100644 index 56b5a84f92a9..000000000000 --- a/src/main/ssh/ssh-connection-channel-lifetime.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it } from 'vitest' -import { trackSshConnectionChannelLifetime } from './ssh-connection-channel-lifetime' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -const signal = () => new AbortController().signal - -it('settles the opening only when the channel emits close', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), channel) - const fence = ledger.fenceForReset() - channel.emit('end') - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - channel.emit('close') - await fence.drain(signal()) - expect(channel.listenerCount('error')).toBe(0) -}) - -it('treats an already closed channel as settled', async () => { - const ledger = new SshConnectionWorkLedger() - trackSshConnectionChannelLifetime( - ledger.beginChannelOpen(), - Object.assign(new EventEmitter(), { closed: true }) - ) - await ledger.fenceForReset().drain(signal()) -}) - -it('keeps a fenced channel error unverifiable even after it closes', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), channel) - const fence = ledger.fenceForReset() - const failure = new Error('channel write failed') - channel.emit('error', failure) - channel.emit('close') - await expect(fence.drain(signal())).rejects.toBe(failure) -}) - -it('does not carry a pre-fence error from a channel that closed before the fence', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), channel) - channel.emit('error', new Error('closed before reset')) - channel.emit('close') - await ledger.fenceForReset().drain(signal()) -}) - -it.each([undefined, null, {}, { on: () => {} }])( - 'marks an untrackable open result (%s) unverifiable instead of settled', - async (value) => { - const ledger = new SshConnectionWorkLedger() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), value) - await expect(ledger.fenceForReset().drain(signal())).rejects.toThrow( - 'ssh_connection_channel_lifetime_unverifiable' - ) - } -) - -it('runs the injected admission check before admitting work', () => { - const ledger = new SshConnectionWorkLedger(undefined, () => { - throw new Error('connection disposed') - }) - expect(() => ledger.beginChannelOpen()).toThrow('connection disposed') -}) diff --git a/src/main/ssh/ssh-connection-channel-lifetime.ts b/src/main/ssh/ssh-connection-channel-lifetime.ts deleted file mode 100644 index c6ad86100bcf..000000000000 --- a/src/main/ssh/ssh-connection-channel-lifetime.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { - SshConnectionWorkChannel, - SshConnectionWorkLedger -} from './ssh-connection-work-ledger' - -type TrackableChannel = SshConnectionWorkChannel & { - closed?: unknown -} - -function isTrackableChannel(value: unknown): value is TrackableChannel { - if (typeof value !== 'object' || value === null) { - return false - } - return ( - 'on' in value && - typeof value.on === 'function' && - 'once' in value && - typeof value.once === 'function' && - 'removeListener' in value && - typeof value.removeListener === 'function' - ) -} - -export function openTrackedSshSocket( - ledger: SshConnectionWorkLedger, - open: () => T -): T { - const work = ledger.beginChannelOpen() - try { - const socket = open() - trackSshConnectionChannelLifetime(work, socket) - return socket - } catch (error) { - work.markUnverifiable(error instanceof Error ? error : new Error(String(error))) - throw error - } -} - -/** Start tracking before the open callback hands the channel to another owner. */ -export function trackSshConnectionChannelLifetime( - work: ReturnType, - value: unknown -): void { - if (!isTrackableChannel(value)) { - work.markUnverifiable(new Error('ssh_connection_channel_lifetime_unverifiable')) - return - } - const channel = value - work.bind(channel) - if (channel.closed === true) { - work.close() - return - } - const onError = (error: Error) => work.markUnverifiable(error) - channel.on('error', onError) - channel.once('close', () => { - channel.removeListener('error', onError) - work.close() - }) -} diff --git a/src/main/ssh/ssh-connection-work-ledger.test.ts b/src/main/ssh/ssh-connection-work-ledger.test.ts deleted file mode 100644 index 25fa62e36531..000000000000 --- a/src/main/ssh/ssh-connection-work-ledger.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it } from 'vitest' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -const signal = () => new AbortController().signal - -it('allows admitted work to open nested channels after fencing and drains children independently', async () => { - const ledger = new SshConnectionWorkLedger() - const continueOperation = Promise.withResolvers() - let child!: ReturnType - const parent = ledger.run(async () => { - await continueOperation.promise - await ledger.run(async () => { - child = ledger.beginChannelOpen() - child.bind(new EventEmitter()) - }) - }) - const fence = ledger.fenceForReset() - let drained = false - const draining = fence.drain(signal()).then(() => { - drained = true - }) - continueOperation.resolve() - await parent - expect(drained).toBe(false) - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - child.close() - await draining - fence.assertDrained() -}) - -it('refuses stale async scope work once its admitted parent has settled', async () => { - const ledger = new SshConnectionWorkLedger() - const releaseStale = Promise.withResolvers() - const staleResult = Promise.withResolvers() - await ledger.run(async () => { - void releaseStale.promise.then(() => { - try { - ledger.beginChannelOpen() - staleResult.resolve('incorrectly admitted') - } catch (error) { - staleResult.resolve(error) - } - }) - }) - const fence = ledger.fenceForReset() - releaseStale.resolve() - expect(await staleResult.promise).toMatchObject({ - message: 'ssh_connection_work_admission_closed' - }) - await fence.drain(signal()) -}) - -it('exempts only the exact captured control channel and keeps ordinary admission fenced', async () => { - const ledger = new SshConnectionWorkLedger() - const control = ledger.beginChannelOpen() - const resource = new EventEmitter() - control.bind(resource) - const transfer = ledger.beginChannelOpen() - transfer.bind(new EventEmitter()) - expect(() => ledger.fenceForReset(new EventEmitter())).toThrow( - 'ssh_connection_work_control_channel_unproven' - ) - const fence = ledger.fenceForReset(resource) - expect(() => ledger.beginChannelOpen()).toThrow('ssh_connection_work_admission_closed') - await expect(ledger.run(async () => undefined)).rejects.toThrow( - 'ssh_connection_work_admission_closed' - ) - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - transfer.close() - await fence.drain(signal()) - fence.assertDrained() - control.close(new Error('expected reset transport close')) - fence.assertDrained() - expect(() => ledger.fenceForReset(resource)).toThrow('ssh_connection_work_already_fenced') -}) - -it('refuses ambiguous control-channel bindings without publishing a fence', async () => { - const ledger = new SshConnectionWorkLedger() - const resource = new EventEmitter() - const first = ledger.beginChannelOpen() - const second = ledger.beginChannelOpen() - first.bind(resource) - second.bind(resource) - expect(() => ledger.fenceForReset(resource)).toThrow( - 'ssh_connection_work_control_channel_unproven' - ) - await ledger.run(async () => undefined) - second.close() - await ledger.fenceForReset(resource).drain(signal()) -}) - -it('keeps uncertain pre-fence openings unconfirmed after late channel close', async () => { - const ledger = new SshConnectionWorkLedger() - const opening = ledger.beginChannelOpen() - const uncertainty = new Error('open timed out without closure evidence') - opening.markUnverifiable(uncertainty) - const fence = ledger.fenceForReset() - await expect(fence.drain(signal())).rejects.toBe(uncertainty) - opening.bind(new EventEmitter()) - opening.close() - expect(fence.assertDrained).toThrow(uncertainty) - await expect(fence.drain(signal())).rejects.toBe(uncertainty) -}) - -it('retains operation failures observed while draining', async () => { - const ledger = new SshConnectionWorkLedger() - const operation = Promise.withResolvers() - const failure = new Error('upload failed') - const running = ledger.run(() => operation.promise).catch((error: unknown) => error) - const fence = ledger.fenceForReset() - const draining = fence.drain(signal()).catch((error: unknown) => error) - operation.reject(failure) - expect(await running).toBe(failure) - expect(await draining).toBe(failure) - expect(fence.assertDrained).toThrow(failure) - await expect(fence.drain(signal())).rejects.toBe(failure) -}) - -it('retains a channel failure even after repeated successful close calls', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = ledger.beginChannelOpen() - const fence = ledger.fenceForReset() - const failure = new Error('channel failed') - channel.close(failure) - channel.close() - expect(fence.assertDrained).toThrow(failure) - await expect(fence.drain(signal())).rejects.toBe(failure) -}) - -it('wakes an in-flight drain when an opening becomes unverifiable without waiting for close', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = ledger.beginChannelOpen() - const fence = ledger.fenceForReset() - const failure = new Error('opening outcome became unverifiable') - let observed: unknown - const draining = fence.drain(signal()).catch((error: unknown) => { - observed = error - }) - channel.markUnverifiable(failure) - await new Promise((resolve) => setImmediate(resolve)) - const observedBeforeClose = observed - channel.close() - await draining - expect(observedBeforeClose).toBe(failure) -}) - -it('reports one failed channel without waiting for unrelated admitted channels to close', async () => { - const ledger = new SshConnectionWorkLedger() - const failedChannel = ledger.beginChannelOpen() - const liveChannel = ledger.beginChannelOpen() - const fence = ledger.fenceForReset() - const failure = new Error('selected transfer failed') - let observed: unknown - const draining = fence.drain(signal()).catch((error: unknown) => { - observed = error - }) - failedChannel.close(failure) - await new Promise((resolve) => setImmediate(resolve)) - const observedBeforeOtherClose = observed - liveChannel.close() - await draining - expect(observedBeforeOtherClose).toBe(failure) -}) - -it('cancels only the wait and permits exact drain retry without reopening admission', async () => { - const ledger = new SshConnectionWorkLedger() - const opening = ledger.beginChannelOpen() - const fence = ledger.fenceForReset() - const controller = new AbortController() - const cancellation = new Error('user cancelled waiting') - const draining = fence.drain(controller.signal).catch((error: unknown) => error) - controller.abort(cancellation) - expect(await draining).toBe(cancellation) - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - expect(() => ledger.beginChannelOpen()).toThrow('ssh_connection_work_admission_closed') - opening.bind(new EventEmitter()) - opening.close() - await fence.drain(signal()) - fence.assertDrained() -}) - -it('isolates admission, async ancestry, failures, and control identities between connections', async () => { - const first = new SshConnectionWorkLedger() - const second = new SshConnectionWorkLedger() - const resource = new EventEmitter() - first.beginChannelOpen().bind(resource) - const firstFence = first.fenceForReset(resource) - await second.run(async () => { - expect(() => first.beginChannelOpen()).toThrow('ssh_connection_work_admission_closed') - }) - expect(() => second.fenceForReset(resource)).toThrow( - 'ssh_connection_work_control_channel_unproven' - ) - const channel = second.beginChannelOpen() - const secondFence = second.fenceForReset() - channel.close(new Error('second connection failure')) - await expect(secondFence.drain(signal())).rejects.toThrow('second connection failure') - await firstFence.drain(signal()) -}) - -it('rejects rebinding and binding an already settled opening', () => { - const ledger = new SshConnectionWorkLedger() - const channel = ledger.beginChannelOpen() - channel.bind(new EventEmitter()) - expect(() => channel.bind(new EventEmitter())).toThrow( - 'ssh_connection_work_channel_binding_changed' - ) - const closed = ledger.beginChannelOpen() - closed.close() - expect(() => closed.bind(new EventEmitter())).toThrow( - 'ssh_connection_work_channel_binding_changed' - ) -}) - -it.each([false, true])( - 'preserves drained proof after exact exempt control closes (error=%s)', - async (withError) => { - const ledger = new SshConnectionWorkLedger() - const control = ledger.beginChannelOpen() - const resource = new EventEmitter() - control.bind(resource) - const fence = ledger.fenceForReset(resource) - await fence.drain(signal()) - if (withError) { - control.markUnverifiable(new Error('control stream closed after response')) - } - control.close(withError ? new Error('control closed') : undefined) - fence.assertDrained() - await fence.drain(signal()) - expect(() => ledger.beginChannelOpen()).toThrow('admission_closed') - } -) - -it('control closure cannot erase retained unrelated work or transport failure', async () => { - for (const transportFailure of [false, true]) { - const ledger = new SshConnectionWorkLedger() - const control = ledger.beginChannelOpen() - const resource = new EventEmitter() - control.bind(resource) - const work = ledger.beginChannelOpen() - const fence = ledger.fenceForReset(resource) - if (transportFailure) { - work.close() - await fence.drain(signal()) - ledger.markTransportUnverifiable() - } else { - work.close(new Error('unrelated write failed')) - } - control.close() - expect(fence.assertDrained).toThrow( - transportFailure ? 'transport_unverifiable' : 'unrelated write failed' - ) - await expect(fence.drain(signal())).rejects.toThrow() - } -}) diff --git a/src/main/ssh/ssh-connection-work-ledger.ts b/src/main/ssh/ssh-connection-work-ledger.ts deleted file mode 100644 index 183bc270a396..000000000000 --- a/src/main/ssh/ssh-connection-work-ledger.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' - -/** Identity of a channel or socket; only compared by reference. */ -export type SshConnectionWorkChannel = Pick - -type WorkEntry = { - done: Promise - finish: () => void - resource?: SshConnectionWorkChannel - failure?: Error -} - -export class SshConnectionWorkAdmissionClosedError extends Error { - constructor() { - super('ssh_connection_work_admission_closed') - this.name = 'SshConnectionWorkAdmissionClosedError' - } -} - -/** Tracks local operation/channel lifetime; channel closure is not remote process-exit proof. */ -export class SshConnectionWorkLedger { - private readonly pending = new Set() - private readonly scope = new AsyncLocalStorage() - private fenced = false - private exempt: WorkEntry | undefined - private failure: Error | undefined - private readonly failureWake = Promise.withResolvers() - - constructor( - private readonly onChange: () => void = () => {}, - private readonly assertAdmission: () => void = () => {} - ) {} - - isDrained(): boolean { - return this.pending.size === 0 && !this.failure - } - - private recordFailure(error: Error): void { - this.failure ??= error - this.failureWake.resolve(this.failure) - } - - markTransportUnverifiable(): void { - if (this.fenced) { - this.recordFailure(new Error('ssh_connection_reset_transport_unverifiable')) - } - } - - private admit(): WorkEntry { - this.assertAdmission() - const parent = this.scope.getStore() - if (this.fenced && (!parent || !this.pending.has(parent) || parent === this.exempt)) { - throw new SshConnectionWorkAdmissionClosedError() - } - const completion = Promise.withResolvers() - const entry: WorkEntry = { done: completion.promise, finish: completion.resolve } - this.pending.add(entry) - return entry - } - - private settle(entry: WorkEntry, error?: unknown): void { - if (!this.pending.delete(entry)) { - return - } - if (error !== undefined) { - entry.failure = error instanceof Error ? error : new Error(String(error)) - } - if (this.fenced && entry !== this.exempt && entry.failure) { - this.recordFailure(entry.failure) - } - entry.finish() - this.onChange() - } - - async run(operation: () => Promise): Promise { - const entry = this.admit() - return this.scope.run(entry, async () => { - try { - const result = await operation() - this.settle(entry) - return result - } catch (error) { - this.settle(entry, error) - throw error - } - }) - } - - beginSession() { - const entry = this.admit() - let closed = false - let closeFailed = false - let active = 0 - const finish = () => { - if (closed && !closeFailed && active === 0) { - this.settle(entry) - } - } - return { - run: async (operation: () => Promise): Promise => { - if (closed) { - throw new Error('ssh_connection_work_session_closed') - } - active++ - try { - return await this.scope.run(entry, () => this.run(operation)) - } catch (error) { - entry.failure ??= error instanceof Error ? error : new Error(String(error)) - if (this.fenced) { - this.recordFailure(entry.failure) - } - throw error - } finally { - active-- - finish() - } - }, - close: (error?: unknown) => { - closed = true - if (error !== undefined) { - // Failed closure must remain observable even if reset has not started yet. - closeFailed = true - entry.failure ??= error instanceof Error ? error : new Error(String(error)) - if (this.fenced) { - this.recordFailure(entry.failure) - } - } - finish() - } - } - } - - beginChannelOpen() { - const entry = this.admit() - let bound = false - return { - bind: (resource: SshConnectionWorkChannel) => { - if (bound || !this.pending.has(entry)) { - throw new Error('ssh_connection_work_channel_binding_changed') - } - entry.resource = resource - bound = true - }, - markUnverifiable: (error: Error) => { - if (this.pending.has(entry)) { - entry.failure ??= error - if (this.fenced && entry !== this.exempt) { - this.recordFailure(error) - } - } - }, - close: (error?: unknown) => this.settle(entry, error) - } - } - - fenceForReset(controlChannel?: SshConnectionWorkChannel) { - if (this.fenced) { - throw new Error('ssh_connection_work_already_fenced') - } - const matches = controlChannel - ? [...this.pending].filter((entry) => entry.resource === controlChannel) - : [] - if (controlChannel && matches.length !== 1) { - throw new Error('ssh_connection_work_control_channel_unproven') - } - this.exempt = matches[0] - this.fenced = true - const assertDrained = () => { - if (this.failure) { - throw this.failure - } - if ([...this.pending].some((entry) => entry !== this.exempt)) { - throw new Error('ssh_connection_work_not_drained') - } - } - return { - drain: async (signal: AbortSignal) => { - signal.throwIfAborted() - for (;;) { - if (this.failure) { - throw this.failure - } - const pending = [...this.pending].filter((entry) => entry !== this.exempt) - for (const entry of pending) { - if (entry.failure) { - throw entry.failure - } - } - if (pending.length === 0) { - assertDrained() - return - } - await waitForPromiseWithSignal( - Promise.race([ - Promise.all(pending.map((entry) => entry.done)), - this.failureWake.promise.then((error) => { - throw error - }) - ]), - signal - ) - } - }, - assertDrained - } - } -} diff --git a/src/main/ssh/ssh-forward-socket-admission.test.ts b/src/main/ssh/ssh-forward-socket-admission.test.ts deleted file mode 100644 index e337eab68658..000000000000 --- a/src/main/ssh/ssh-forward-socket-admission.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import { openTrackedSshSocket } from './ssh-connection-channel-lifetime' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -it('acquires admission before a socket factory can connect', () => { - const ledger = new SshConnectionWorkLedger() - const open = vi.fn(() => new EventEmitter()) - ledger.fenceForReset() - expect(() => openTrackedSshSocket(ledger, open)).toThrow('admission_closed') - expect(open).not.toHaveBeenCalled() -}) - -it('waits for actual close rather than the destroyed flag', async () => { - const ledger = new SshConnectionWorkLedger() - const socket = openTrackedSshSocket(ledger, () => - Object.assign(new EventEmitter(), { destroyed: false }) - ) - const fence = ledger.fenceForReset() - socket.destroyed = true - expect(fence.assertDrained).toThrow('not_drained') - socket.emit('close') - await fence.drain(new AbortController().signal) -}) - -it('retains socket-construction uncertainty without accepting a later reset', async () => { - const ledger = new SshConnectionWorkLedger() - const failure = new Error('socket setup failed') - expect(() => - openTrackedSshSocket(ledger, () => { - throw failure - }) - ).toThrow(failure) - await expect(ledger.fenceForReset().drain(new AbortController().signal)).rejects.toBe(failure) -}) diff --git a/src/main/ssh/ssh-transport-close-ledger.test.ts b/src/main/ssh/ssh-transport-close-ledger.test.ts deleted file mode 100644 index b126c9cb0b10..000000000000 --- a/src/main/ssh/ssh-transport-close-ledger.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import { SshTransportCloseLedger } from './ssh-transport-close-ledger' - -function fixture() { - const ledger = new SshTransportCloseLedger() - const allocate = () => { - const client = new EventEmitter() - ledger.track(client) - return client - } - return { ledger, allocate } -} - -it('waits for all allocated clients, including failed authentication attempts', async () => { - const { ledger, allocate } = fixture() - const failed = allocate() - const successful = allocate() - const done = vi.fn() - const draining = ledger.drain(new AbortController().signal).then(done) - failed.emit('end') - successful.emit('close') - await Promise.resolve() - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - failed.emit('close') - await draining - expect(done).toHaveBeenCalledOnce() - expect(failed.listenerCount('close')).toBe(0) - expect(successful.listenerCount('close')).toBe(0) -}) - -it('remembers closure before drain and does not accumulate listeners', async () => { - const { ledger, allocate } = fixture() - for (let i = 0; i < 100; i++) { - const client = allocate() - ledger.track(client) - expect(client.listenerCount('close')).toBe(1) - client.emit('close') - expect(client.listenerCount('close')).toBe(0) - } - await ledger.drain(new AbortController().signal) - await ledger.drain(new AbortController().signal) -}) - -it('retains physical closure evidence across canceled waits', async () => { - const { ledger, allocate } = fixture() - const client = allocate() - const controller = new AbortController() - const draining = ledger.drain(controller.signal) - controller.abort() - await expect(draining).rejects.toThrow() - expect(client.listenerCount('close')).toBe(1) - const retry = ledger.drain(new AbortController().signal) - client.emit('close') - await retry - expect(client.listenerCount('close')).toBe(0) -}) - -it('refuses new outstanding allocations made after the drain snapshot', async () => { - const { ledger, allocate } = fixture() - const original = allocate() - const draining = ledger.drain(new AbortController().signal) - const late = allocate() - original.emit('close') - await expect(draining).rejects.toThrow('ssh_client_close_allocations_changed') - late.emit('close') - await ledger.drain(new AbortController().signal) -}) diff --git a/src/main/ssh/ssh-transport-close-ledger.ts b/src/main/ssh/ssh-transport-close-ledger.ts deleted file mode 100644 index b9721ea71fc7..000000000000 --- a/src/main/ssh/ssh-transport-close-ledger.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { EventEmitter } from 'node:events' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' - -/** Authentication settlement does not establish physical socket closure. */ -export class SshTransportCloseLedger { - private readonly pending = new Map>() - - constructor(private readonly onChange: () => void = () => {}) {} - - isClosed(): boolean { - return this.pending.size === 0 - } - - track(client: EventEmitter): void { - if (this.pending.has(client)) { - return - } - let resolveClose!: () => void - const closed = new Promise((resolve) => { - resolveClose = resolve - }) - const onClose = (): void => { - client.off('close', onClose) - this.pending.delete(client) - resolveClose() - this.onChange() - } - this.pending.set(client, closed) - client.on('close', onClose) - } - - /** Caller must fence new allocations before taking this snapshot. */ - async drain(signal: AbortSignal): Promise { - await waitForPromiseWithSignal(Promise.all(this.pending.values()), signal) - if (this.pending.size !== 0) { - throw new Error('ssh_client_close_allocations_changed') - } - } -} diff --git a/src/main/ssh/ssh-transport-close-observation.test.ts b/src/main/ssh/ssh-transport-close-observation.test.ts deleted file mode 100644 index c12950110b10..000000000000 --- a/src/main/ssh/ssh-transport-close-observation.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import { observeSshTransportClose } from './ssh-transport-close-observation' - -it('resolves only after every observed resource emits close', async () => { - const client = new EventEmitter() - client.on('error', () => {}) - const proxy = new EventEmitter() - const observation = observeSshTransportClose([client, proxy]) - const done = vi.fn() - const waiting = observation.wait(new AbortController().signal).then(done) - client.emit('end') - client.emit('error', new Error('reset by peer')) - client.emit('close') - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - proxy.emit('close') - await waiting - expect(done).toHaveBeenCalledOnce() -}) - -it('cancels only the wait and removes its listeners on dispose', async () => { - const client = new EventEmitter() - client.on('error', () => {}) - const observation = observeSshTransportClose([client]) - const controller = new AbortController() - const waiting = observation.wait(controller.signal) - controller.abort(new Error('caller gave up')) - await expect(waiting).rejects.toThrow('caller gave up') - expect(client.listenerCount('close')).toBe(1) - observation.dispose() - expect(client.listenerCount('close')).toBe(0) -}) diff --git a/src/main/ssh/ssh-transport-close-observation.ts b/src/main/ssh/ssh-transport-close-observation.ts deleted file mode 100644 index 5f604303d421..000000000000 --- a/src/main/ssh/ssh-transport-close-observation.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { EventEmitter } from 'node:events' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' - -/** Register before initiating close; 'end', 'error' and destroy requests are not closure. */ -export function observeSshTransportClose(resources: readonly EventEmitter[]) { - const listeners: { resource: EventEmitter; close: () => void }[] = [] - const closed = Promise.all( - resources.map( - (resource) => - new Promise((resolve) => { - const close = () => resolve() - listeners.push({ resource, close }) - resource.once('close', close) - }) - ) - ) - return { - wait: (signal: AbortSignal) => waitForPromiseWithSignal(closed, signal), - dispose: () => { - for (const { resource, close } of listeners) { - resource.removeListener('close', close) - } - } - } -} From 93915c29c7e84a664bddebc74f4942db89adbedc Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:05 -0700 Subject: [PATCH 23/26] Revert "feat(relay): fence and drain file and git response streams on shutdown (#24185)" This reverts commit dc08ffeba9603c5b48b9b100d0a7f89b021e6d1b. --- src/relay/fs-handler-file-read.ts | 95 +++++----- src/relay/fs-handler-stream.test.ts | 23 --- src/relay/fs-handler-utils.ts | 8 +- src/relay/fs-handler.ts | 8 +- src/relay/fs-stream-registry-shutdown.test.ts | 171 ----------------- src/relay/fs-stream-registry.ts | 86 ++------- src/relay/fs-stream-terminal-frame-slots.ts | 51 ----- .../git-response-stream-shutdown.test.ts | 176 ------------------ src/relay/git-response-stream.ts | 26 +-- ...lay-runtime-owned-process-shutdown.test.ts | 72 ------- src/relay/relay-runtime-services.ts | 18 -- 11 files changed, 66 insertions(+), 668 deletions(-) delete mode 100644 src/relay/fs-stream-registry-shutdown.test.ts delete mode 100644 src/relay/fs-stream-terminal-frame-slots.ts delete mode 100644 src/relay/git-response-stream-shutdown.test.ts delete mode 100644 src/relay/relay-runtime-owned-process-shutdown.test.ts diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index c07c897fc8b0..e8b7993fbcdc 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -2,9 +2,8 @@ import { open, readFile, stat } from 'node:fs/promises' import type { FileHandle } from 'node:fs/promises' import { extname } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' -import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE } from './protocol' -import type { RelayStreamRegistry } from './fs-stream-registry' -import { reserveTerminalFrameSlot } from './fs-stream-terminal-frame-slots' +import { MAX_CONCURRENT_STREAMS, STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE } from './protocol' +import { TooManyStreamsError, type RelayStreamRegistry } from './fs-stream-registry' import { BINARY_PROBE_BYTES, IMAGE_MIME_TYPES, @@ -78,21 +77,6 @@ export async function readRelayFileStreamMetadata( registry: RelayStreamRegistry, context: RequestContext, pumpOptions?: StreamPumpOptions -): Promise { - const finish = registry.beginOperation() - try { - return await prepareRelayFileStream(filePath, dispatcher, registry, context, pumpOptions) - } finally { - finish() - } -} - -async function prepareRelayFileStream( - filePath: string, - dispatcher: RelayDispatcher, - registry: RelayStreamRegistry, - context: RequestContext, - pumpOptions?: StreamPumpOptions ): Promise { const stats = await stat(filePath) const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] @@ -115,10 +99,7 @@ async function prepareRelayFileStream( // Why: unlike the legacy single-shot path, streaming does not read the full // buffer before classifying content. Probe every unknown file so small binary // files do not get decoded as UTF-8 text over SSH. - if ( - !mimeType && - (await isBinaryFilePrefix(filePath, (handle) => registry.releaseUnregisteredHandle(handle))) - ) { + if (!mimeType && (await isBinaryFilePrefix(filePath))) { return { totalSize: 0, isBinary: true, empty: true } } @@ -131,13 +112,8 @@ async function prepareRelayFileStream( handle = await open(filePath, 'r') streamId = registry.register(handle) } catch (err) { - try { - if (handle) { - await registry.releaseUnregisteredHandle(handle) - } - } finally { - releaseTerminalFrameSlot() - } + await handle?.close() + releaseTerminalFrameSlot() throw err } @@ -147,7 +123,6 @@ async function prepareRelayFileStream( // setImmediate kicks the pump off the metadata-response task so the client // sees the response before the first chunk frame. const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false } - const finishPump = registry.beginOperation() setImmediate(() => { void pumpChunks( streamId, @@ -158,10 +133,6 @@ async function prepareRelayFileStream( resolvedPumpOptions, releaseTerminalFrameSlot ) - .catch((error: unknown) => { - process.stderr.write(`[relay] stream cleanup failed id=${streamId}: ${String(error)}\n`) - }) - .finally(finishPump) }) return { @@ -175,6 +146,48 @@ async function prepareRelayFileStream( } } +/** + * Terminal frames (fs.streamEnd/fs.streamError) ride the control lane, which does not + * drop on overflow — it destroys the link at 256 queued frames / 1 MB. A stream's + * registry slot is gone the moment its last chunk is read, so on a socket that is not + * draining, back-to-back reads could stack one undelivered terminal frame each until + * that budget blew. Holding the slot until the frame settles keeps the number of queued + * terminal frames at MAX_CONCURRENT_STREAMS, far below the killing threshold; the + * overflow now costs one refused read (TooManyStreams, which clients already handle) + * instead of the whole connection. + * + * Counted per client, because the control queue this protects is per client: one peer whose + * socket stopped draining must not refuse reads for every other peer on the same relay. + */ +const pendingTerminalFramesByClient = new WeakMap>() + +function reserveTerminalFrameSlot(registry: RelayStreamRegistry, clientId: number): () => void { + let byClient = pendingTerminalFramesByClient.get(registry) + if (!byClient) { + byClient = new Map() + pendingTerminalFramesByClient.set(registry, byClient) + } + const pending = byClient.get(clientId) ?? 0 + if (pending >= MAX_CONCURRENT_STREAMS) { + throw new TooManyStreamsError() + } + byClient.set(clientId, pending + 1) + let released = false + return () => { + if (released) { + return + } + released = true + const remaining = (byClient.get(clientId) ?? 1) - 1 + // Drop the entry at zero so a long-lived registry cannot accumulate one per detached client. + if (remaining <= 0) { + byClient.delete(clientId) + return + } + byClient.set(clientId, remaining) + } +} + async function pumpChunks( streamId: number, totalSize: number, @@ -289,11 +302,6 @@ async function pumpChunks( releaseTerminalFrameSlot ) } - if (registry.isAborted(streamId)) { - endReason = 'aborted' - } else if (context.isStale()) { - endReason = 'stale' - } if (endReason === 'end') { publishTerminal('fs.streamEnd', { streamId }) process.stderr.write(`[relay] stream end id=${streamId}\n`) @@ -317,12 +325,9 @@ async function pumpChunks( } finally { // Why: the fd goes back first — a terminal frame that can never be delivered must not // strand it. Cancelled/stale streams publish nothing, so nothing else frees their slot. - try { - await registry.release(streamId) - } finally { - if (!slotReleaseDeferred) { - releaseTerminalFrameSlot() - } + await registry.release(streamId) + if (!slotReleaseDeferred) { + releaseTerminalFrameSlot() } } } diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts index 9cbfd6873766..ba6cfbf1877e 100644 --- a/src/relay/fs-handler-stream.test.ts +++ b/src/relay/fs-handler-stream.test.ts @@ -492,29 +492,6 @@ describe('FsHandler readFileStream', () => { await waitFor(() => terminalFrames().length === MAX_CONCURRENT_STREAMS + 2) }) - it('holds file stream shutdown until an undelivered terminal frame settles', async () => { - const filePath = path.join(tmpDir, 'shutdown-terminal.png') - writeFileSync(filePath, Buffer.alloc(STREAM_CHUNK_SIZE, 0x42)) - dispatcher.holdControlSettlements() - const context = { clientId: 4, isStale: () => false } - await dispatcher.callRequest('fs.readFileStream', { filePath }, context) - await waitFor(() => collectStream(dispatcher).end !== null) - - let drained = false - const drain = handler.disposeFileStreams().then(() => { - drained = true - }) - await flush(10) - expect(drained).toBe(false) - await expect( - dispatcher.callRequest('fs.readFileStream', { filePath }, context) - ).rejects.toThrow('relay_file_stream_shutdown_fenced') - - dispatcher.settleHeldControlFrames() - await drain - expect(drained).toBe(true) - }) - it('rejects the 17th concurrent stream with TooManyStreams', async () => { const paths: string[] = [] for (let i = 0; i < 17; i++) { diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index 0696b48046d5..a3b2234ced04 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -64,18 +64,14 @@ export function isBinaryBuffer(buffer: Buffer): boolean { return false } -export async function isBinaryFilePrefix( - filePath: string, - releaseHandle: (handle: Awaited>) => Promise = (handle) => - handle.close() -): Promise { +export async function isBinaryFilePrefix(filePath: string): Promise { const handle = await open(filePath, 'r') try { const probe = Buffer.alloc(BINARY_PROBE_BYTES) const { bytesRead } = await handle.read(probe, 0, probe.length, 0) return isBinaryBuffer(probe.subarray(0, bytesRead)) } finally { - await releaseHandle(handle) + await handle.close() } } diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 39b229847663..36765721ef33 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -267,12 +267,6 @@ export class FsHandler { dispose(): void { this.watchRegistry.dispose() - void this.disposeFileStreams().catch((error: unknown) => { - process.stderr.write(`[relay] file stream shutdown failed: ${String(error)}\n`) - }) - } - - disposeFileStreams(): Promise { - return this.streamRegistry.disposeAll() + void this.streamRegistry.disposeAll() } } diff --git a/src/relay/fs-stream-registry-shutdown.test.ts b/src/relay/fs-stream-registry-shutdown.test.ts deleted file mode 100644 index c79dfb860d47..000000000000 --- a/src/relay/fs-stream-registry-shutdown.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { FileHandle } from 'node:fs/promises' -import { describe, expect, it, vi } from 'vitest' -import { RelayStreamRegistry } from './fs-stream-registry' - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -async function flushTasks(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -function handle(close: ReturnType): FileHandle { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry only ever calls close() on a handle. - return { close } as unknown as FileHandle -} - -describe('RelayStreamRegistry shutdown', () => { - it('retains failed late pre-registration cleanup after the initial disposal snapshot', async () => { - const registry = new RelayStreamRegistry() - const finishMetadata = registry.beginOperation() - const failure = new Error('late handle close failed') - const close = vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(undefined) - const result = registry.disposeAll().catch((error: unknown) => error) - await flushTasks() - const openedHandle = handle(close) - expect(() => registry.register(openedHandle)).toThrow('relay_file_stream_shutdown_fenced') - await expect(registry.releaseUnregisteredHandle(openedHandle)).rejects.toBe(failure) - finishMetadata() - expect(await result).toMatchObject({ - message: 'relay_file_stream_shutdown_incomplete', - errors: [failure] - }) - expect(registry.size()).toBe(1) - await registry.disposeAll() - expect(registry.size()).toBe(0) - expect(close).toHaveBeenCalledTimes(2) - }) - - it('waits for a late pre-registration close owned by an admitted metadata operation', async () => { - const registry = new RelayStreamRegistry() - const finishMetadata = registry.beginOperation() - const pending = deferred() - const close = vi.fn(() => pending.promise) - const finished = vi.fn() - const drain = registry.disposeAll().then(finished) - await flushTasks() - const cleanup = registry.releaseUnregisteredHandle(handle(close)).finally(finishMetadata) - await flushTasks() - expect(finished).not.toHaveBeenCalled() - pending.resolve() - await Promise.all([cleanup, drain]) - expect(registry.size()).toBe(0) - }) - - it('joins a release already waiting for the file handle to close', async () => { - const registry = new RelayStreamRegistry() - const pending = deferred() - const close = vi.fn(() => pending.promise) - const id = registry.register(handle(close)) - const release = registry.release(id) - let disposed = false - const dispose = registry.disposeAll().then(() => { - disposed = true - }) - - try { - await flushTasks() - expect(disposed).toBe(false) - expect(close).toHaveBeenCalledTimes(1) - expect(registry.isAborted(id)).toBe(true) - } finally { - pending.resolve() - await Promise.all([release, dispose]) - } - expect(disposed).toBe(true) - expect(registry.size()).toBe(0) - }) - - it('coalesces concurrent release calls until the single close settles', async () => { - const registry = new RelayStreamRegistry() - const pending = deferred() - const close = vi.fn(() => pending.promise) - const id = registry.register(handle(close)) - const first = registry.release(id) - let secondSettled = false - const second = registry.release(id).then(() => { - secondSettled = true - }) - - try { - await flushTasks() - expect(close).toHaveBeenCalledTimes(1) - expect(secondSettled).toBe(false) - } finally { - pending.resolve() - await Promise.all([first, second]) - } - expect(registry.size()).toBe(0) - }) - - it('retains a failed close for a later disposal retry', async () => { - const registry = new RelayStreamRegistry() - const failure = Object.assign(new Error('close failed'), { code: 'EIO' }) - const close = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce(undefined) - const id = registry.register(handle(close)) - - await expect(registry.release(id)).rejects.toBe(failure) - expect(registry.size()).toBe(1) - expect(registry.isAborted(id)).toBe(true) - await registry.disposeAll() - - expect(close).toHaveBeenCalledTimes(2) - expect(registry.size()).toBe(0) - }) - - it('waits for every close before rejecting disposal and retries only failed handles', async () => { - const registry = new RelayStreamRegistry() - const failure = Object.assign(new Error('close failed'), { code: 'EIO' }) - const failedClose = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce(undefined) - const pending = deferred() - const pendingClose = vi.fn(() => pending.promise) - registry.register(handle(failedClose)) - registry.register(handle(pendingClose)) - let settled = false - const result = registry.disposeAll().then( - () => { - settled = true - return undefined - }, - (error: unknown) => { - settled = true - return error - } - ) - - try { - await flushTasks() - expect(settled).toBe(false) - expect(failedClose).toHaveBeenCalledTimes(1) - expect(pendingClose).toHaveBeenCalledTimes(1) - } finally { - pending.resolve() - } - const error = await result - expect(error).toBeInstanceOf(AggregateError) - expect(error).toMatchObject({ errors: [failure] }) - expect(registry.size()).toBe(1) - - await registry.disposeAll() - expect(failedClose).toHaveBeenCalledTimes(2) - expect(pendingClose).toHaveBeenCalledTimes(1) - expect(registry.size()).toBe(0) - }) - - it('tolerates EBADF as an already-closed handle', async () => { - const registry = new RelayStreamRegistry() - const close = vi.fn().mockRejectedValue(Object.assign(new Error('closed'), { code: 'EBADF' })) - const id = registry.register(handle(close)) - - await registry.release(id) - await registry.disposeAll() - - expect(close).toHaveBeenCalledTimes(1) - expect(registry.size()).toBe(0) - }) -}) diff --git a/src/relay/fs-stream-registry.ts b/src/relay/fs-stream-registry.ts index 23a8aab662a8..2bca76c9495e 100644 --- a/src/relay/fs-stream-registry.ts +++ b/src/relay/fs-stream-registry.ts @@ -21,44 +21,11 @@ export class TooManyStreamsError extends Error { export class RelayStreamRegistry { private streams = new Map() private nextId = 1 - private disposed = false - private closing = new Map>() - private closeFailures = new Map() - private operations = new Set>() - - /** Tracks work that may still open or release a handle; disposeAll waits for it. */ - beginOperation(): () => void { - if (this.disposed) { - throw new Error('relay_file_stream_shutdown_fenced') - } - // Why: no Promise.withResolvers — the relay bundle still targets Node 18 hosts. - let resolve!: () => void - const pending = new Promise((settle) => { - resolve = settle - }) - this.operations.add(pending) - return () => { - this.operations.delete(pending) - resolve() - } - } register(handle: FileHandle): number { - if (this.disposed) { - throw new Error('relay_file_stream_shutdown_fenced') - } if (this.streams.size >= MAX_CONCURRENT_STREAMS) { throw new TooManyStreamsError() } - return this.retainHandle(handle) - } - - /** Closes a handle opened outside a stream; a failed close stays retained for disposal retry. */ - releaseUnregisteredHandle(handle: FileHandle): Promise { - return this.release(this.retainHandle(handle)) - } - - private retainHandle(handle: FileHandle): number { const streamId = this.nextId++ this.streams.set(streamId, { handle, @@ -138,43 +105,26 @@ export class RelayStreamRegistry { } } - /** Concurrent callers share one close; only EBADF counts as already closed. */ - release(streamId: number): Promise { - const pending = this.closing.get(streamId) - if (pending) { - return pending - } + async release(streamId: number): Promise { const entry = this.streams.get(streamId) if (!entry) { - return Promise.resolve() + return + } + this.wakeAckWaiters(entry) + this.streams.delete(streamId) + try { + await entry.handle.close() + } catch { + // release runs from multiple exit paths (pump, cancel, dispose); a + // second close throws EBADF — swallow it. } - this.abort(streamId) - const close = Promise.resolve() - .then(() => entry.handle.close()) - .catch((error: unknown) => { - if (!isErrorWithCode(error, 'EBADF')) { - this.closeFailures.set(streamId, error) - throw error - } - }) - .then(() => { - this.streams.delete(streamId) - this.closeFailures.delete(streamId) - }) - .finally(() => { - this.closing.delete(streamId) - }) - this.closing.set(streamId, close) - return close } size(): number { return this.streams.size } - /** Permanently fences new streams; rejects while any handle is still unclosed so a retry can finish. */ async disposeAll(): Promise { - this.disposed = true // Why: flag every stream as aborted so any in-flight pump exits its loop // cleanly on the next iteration boundary instead of seeing EBADF when // release closes the handle out from under an in-flight read. @@ -182,20 +132,6 @@ export class RelayStreamRegistry { this.abort(id) } const ids = Array.from(this.streams.keys()) - const results = await Promise.allSettled(ids.map((id) => this.release(id))) - await Promise.all(this.operations) - const failures = results.filter((result) => result.status === 'rejected') - if (failures.length > 0 || this.streams.size > 0) { - throw new AggregateError( - [ - ...new Set([...failures.map((failure) => failure.reason), ...this.closeFailures.values()]) - ], - 'relay_file_stream_shutdown_incomplete' - ) - } + await Promise.all(ids.map((id) => this.release(id))) } } - -function isErrorWithCode(error: unknown, code: string): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === code -} diff --git a/src/relay/fs-stream-terminal-frame-slots.ts b/src/relay/fs-stream-terminal-frame-slots.ts deleted file mode 100644 index 5ede13b5d79c..000000000000 --- a/src/relay/fs-stream-terminal-frame-slots.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { MAX_CONCURRENT_STREAMS } from './protocol' -import { TooManyStreamsError, type RelayStreamRegistry } from './fs-stream-registry' - -/** - * Terminal frames (fs.streamEnd/fs.streamError) ride the control lane, which does not - * drop on overflow — it destroys the link at 256 queued frames / 1 MB. A stream's - * registry slot is gone the moment its last chunk is read, so on a socket that is not - * draining, back-to-back reads could stack one undelivered terminal frame each until - * that budget blew. Holding the slot until the frame settles keeps the number of queued - * terminal frames at MAX_CONCURRENT_STREAMS, far below the killing threshold; the - * overflow now costs one refused read (TooManyStreams, which clients already handle) - * instead of the whole connection. - * - * Counted per client, because the control queue this protects is per client: one peer whose - * socket stopped draining must not refuse reads for every other peer on the same relay. - * Counted independently of released file descriptors, and each slot is a registry operation - * so shutdown waits for undelivered terminal frames too. - */ -const pendingTerminalFramesByClient = new WeakMap>() - -export function reserveTerminalFrameSlot( - registry: RelayStreamRegistry, - clientId: number -): () => void { - let byClient = pendingTerminalFramesByClient.get(registry) - if (!byClient) { - byClient = new Map() - pendingTerminalFramesByClient.set(registry, byClient) - } - const pending = byClient.get(clientId) ?? 0 - if (pending >= MAX_CONCURRENT_STREAMS) { - throw new TooManyStreamsError() - } - const finish = registry.beginOperation() - byClient.set(clientId, pending + 1) - let released = false - return () => { - if (released) { - return - } - released = true - finish() - const remaining = (byClient.get(clientId) ?? 1) - 1 - // Drop the entry at zero so a long-lived registry cannot accumulate one per detached client. - if (remaining <= 0) { - byClient.delete(clientId) - return - } - byClient.set(clientId, remaining) - } -} diff --git a/src/relay/git-response-stream-shutdown.test.ts b/src/relay/git-response-stream-shutdown.test.ts deleted file mode 100644 index 4ae10c539c90..000000000000 --- a/src/relay/git-response-stream-shutdown.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RelayDispatcher, RequestContext } from './dispatcher' -import { GitResponseStreamRegistry } from './git-response-stream' -import { GIT_RESPONSE_CHUNK_SIZE, STREAM_ACK_WINDOW_CHUNKS } from './protocol' - -const context: RequestContext = { clientId: 7, isStale: () => false } - -async function flushPump(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -describe('GitResponseStreamRegistry shutdown', () => { - const registries: GitResponseStreamRegistry[] = [] - - function fixture(): { - registry: GitResponseStreamRegistry - dispatcher: RelayDispatcher - notifyBulk: ReturnType - } { - const registry = new GitResponseStreamRegistry() - registries.push(registry) - const notifyBulk = vi.fn().mockResolvedValue(undefined) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the response pump only calls notifyBulk (producerDataBudget is optional). - return { registry, notifyBulk, dispatcher: { notifyBulk } as unknown as RelayDispatcher } - } - - afterEach(async () => { - await Promise.all(registries.splice(0).map((registry) => registry.disposeAllAndWait())) - }) - - it('drains a scheduled producer without publishing any frames', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - registry.startStream(Buffer.from('payload'), dispatcher, context) - - await registry.disposeAllAndWait() - await flushPump() - - expect(notifyBulk).not.toHaveBeenCalled() - expect(() => registry.startStream(Buffer.from('next'), dispatcher, context)).toThrow( - 'relay_response_stream_shutdown_fenced' - ) - await registry.disposeAllAndWait() - }) - - it('waits for an in-flight final chunk and never publishes responseEnd after abort', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - const write = deferred() - notifyBulk.mockImplementationOnce(() => write.promise) - registry.startStream(Buffer.from('one chunk'), dispatcher, context) - await flushPump() - expect(notifyBulk).toHaveBeenCalledTimes(1) - - let drained = false - const firstDrain = registry.disposeAllAndWait().then(() => { - drained = true - }) - const secondDrain = registry.disposeAllAndWait() - try { - await flushPump() - expect(drained).toBe(false) - expect(() => registry.startStream(Buffer.from('next'), dispatcher, context)).toThrow( - 'relay_response_stream_shutdown_fenced' - ) - } finally { - write.resolve() - await Promise.all([firstDrain, secondDrain]) - } - - expect(drained).toBe(true) - expect(notifyBulk).toHaveBeenCalledTimes(1) - expect(notifyBulk.mock.calls[0]?.[0]).toBe('git.responseChunk') - }) - - it('wakes producers parked on client ACKs without waiting for the stall timer', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - registry.startStream( - Buffer.alloc(GIT_RESPONSE_CHUNK_SIZE * (STREAM_ACK_WINDOW_CHUNKS + 1)), - dispatcher, - context - ) - await flushPump() - expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) - - let drained = false - const drain = registry.disposeAllAndWait().then(() => { - drained = true - }) - await flushPump() - - expect(drained).toBe(true) - await drain - expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) - expect(notifyBulk.mock.calls.every(([method]) => method === 'git.responseChunk')).toBe(true) - }) - - it('waits for an already-published responseEnd to settle', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - const endWrite = deferred() - notifyBulk.mockResolvedValueOnce(undefined).mockImplementationOnce(() => endWrite.promise) - registry.startStream(Buffer.from('payload'), dispatcher, context) - await flushPump() - expect(notifyBulk.mock.calls.map(([method]) => method)).toEqual([ - 'git.responseChunk', - 'git.responseEnd' - ]) - - let drained = false - const drain = registry.disposeAllAndWait().then(() => { - drained = true - }) - try { - await flushPump() - expect(drained).toBe(false) - } finally { - endWrite.resolve() - await drain - } - expect(drained).toBe(true) - expect(notifyBulk).toHaveBeenCalledTimes(2) - }) - - it('suppresses error publication when an aborted in-flight write rejects', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - let rejectWrite!: (error: Error) => void - notifyBulk.mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - rejectWrite = reject - }) - ) - registry.startStream(Buffer.from('payload'), dispatcher, context) - await flushPump() - - const drain = registry.disposeAllAndWait() - rejectWrite(new Error('channel closed during shutdown')) - await drain - - expect(notifyBulk).toHaveBeenCalledTimes(1) - expect(notifyBulk.mock.calls[0]?.[0]).toBe('git.responseChunk') - }) - - it('synchronous disposal also permanently fences admission and retains the pending drain', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - const write = deferred() - notifyBulk.mockImplementationOnce(() => write.promise) - registry.startStream(Buffer.from('payload'), dispatcher, context) - await flushPump() - registry.disposeAll() - - let drained = false - const drain = registry.disposeAllAndWait().then(() => { - drained = true - }) - try { - expect(() => registry.startStream(Buffer.from('next'), dispatcher, context)).toThrow( - 'relay_response_stream_shutdown_fenced' - ) - await flushPump() - expect(drained).toBe(false) - } finally { - write.resolve() - await drain - } - - expect(notifyBulk).toHaveBeenCalledTimes(1) - await registry.disposeAllAndWait() - }) -}) diff --git a/src/relay/git-response-stream.ts b/src/relay/git-response-stream.ts index 922c8637d3ae..c9d8d2ce2907 100644 --- a/src/relay/git-response-stream.ts +++ b/src/relay/git-response-stream.ts @@ -44,13 +44,8 @@ function encodeChunks(payload: Buffer, chunkBytes = GIT_RESPONSE_CHUNK_SIZE): st export class GitResponseStreamRegistry { private streams = new Map() private nextId = 1 - private disposed = false - private readonly pendingPumps = new Set>() private register(ownerClientId: number): number { - if (this.disposed) { - throw new Error('relay_response_stream_shutdown_fenced') - } const streamId = this.nextId++ this.streams.set(streamId, { ownerClientId, @@ -146,17 +141,8 @@ export class GitResponseStreamRegistry { const chunks = encodeChunks(payload, Math.min(GIT_RESPONSE_CHUNK_SIZE, sinkChunkBytes)) // Why: kick the pump off the response task so the client sees the sentinel // (and can subscribe/reassemble) before the first chunk frame arrives. - // Why: no Promise.withResolvers — the relay bundle still targets Node 18 hosts. - let finish!: () => void - const completion = new Promise((resolve) => { - finish = () => { - this.pendingPumps.delete(completion) - resolve() - } - }) - this.pendingPumps.add(completion) setImmediate(() => { - void this.pump(streamId, chunks, dispatcher, context).then(finish, finish) + void this.pump(streamId, chunks, dispatcher, context) }) return { __orcaGitResponseStream: { streamId, totalBytes: payload.length, chunkCount: chunks.length } @@ -213,8 +199,7 @@ export class GitResponseStreamRegistry { } ) } - // Why: disposal may abort while the final chunk write is in flight. - if (endReason === 'end' && !entry.aborted && !context.isStale()) { + if (endReason === 'end') { await dispatcher.notifyBulk('git.responseEnd', { streamId }, { clientId }) } } catch (err) { @@ -238,20 +223,13 @@ export class GitResponseStreamRegistry { } } - /** Permanently fences new streams and aborts every pump; use disposeAllAndWait to await them. */ disposeAll(): void { - this.disposed = true for (const entry of this.streams.values()) { entry.aborted = true this.wake(entry) } this.streams.clear() } - - async disposeAllAndWait(): Promise { - this.disposeAll() - await Promise.all(this.pendingPumps) - } } /** diff --git a/src/relay/relay-runtime-owned-process-shutdown.test.ts b/src/relay/relay-runtime-owned-process-shutdown.test.ts deleted file mode 100644 index 48644137c734..000000000000 --- a/src/relay/relay-runtime-owned-process-shutdown.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { RelayRuntimeServices } from './relay-runtime-services' - -afterEach(() => vi.restoreAllMocks()) - -function fixture() { - const skill = vi.fn(async () => {}) - const vault = vi.fn(async () => {}) - const responses = vi.fn(async () => {}) - const fileStreams = vi.fn(async () => {}) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: disposeOwnedProcesses only reads the owners stubbed here. - const runtime = Object.assign(Object.create(RelayRuntimeServices.prototype), { - skillInstallHandler: { dispose: skill }, - aiVaultService: { dispose: vault }, - responseStreams: { disposeAllAndWait: responses }, - fsHandler: { disposeFileStreams: fileStreams } - }) as RelayRuntimeServices - return { runtime, skill, vault, responses, fileStreams } -} - -it.each(['responses', 'fileStreams'] as const)( - 'does not acknowledge cleanup before %s have settled', - async (owner) => { - const f = fixture() - let release!: () => void - f[owner].mockReturnValue( - new Promise((resolve) => { - release = resolve - }) - ) - const finished = vi.fn() - const shutdown = f.runtime.disposeOwnedProcesses().then(finished) - expect(f[owner]).toHaveBeenCalledOnce() - await vi.waitFor(() => expect(f.vault).toHaveBeenCalledOnce()) - expect(finished).not.toHaveBeenCalled() - release() - await shutdown - expect(finished).toHaveBeenCalledOnce() - } -) - -it('rejects stream drain failures after attempting every owner and supports retry', async () => { - const f = fixture() - const responseError = new Error('response drain failed') - const fileError = new Error('file drain failed') - f.responses.mockRejectedValueOnce(responseError) - f.fileStreams.mockRejectedValueOnce(fileError) - await expect(f.runtime.disposeOwnedProcesses()).rejects.toMatchObject({ - message: 'relay_owned_process_shutdown_incomplete', - errors: [responseError, fileError] - }) - expect(f.skill).toHaveBeenCalledOnce() - expect(f.vault).toHaveBeenCalledOnce() - await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() -}) - -it('keeps skill and AI Vault cleanup failures log-and-continue', async () => { - const f = fixture() - vi.spyOn(process.stderr, 'write').mockReturnValue(true) - f.skill.mockRejectedValueOnce(new Error('skill cleanup failed')) - f.vault.mockRejectedValueOnce(new Error('vault cleanup failed')) - await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() - expect(f.responses).toHaveBeenCalledOnce() - expect(f.fileStreams).toHaveBeenCalledOnce() -}) - -it('handles hosts without a vault service', async () => { - const f = fixture() - Object.assign(f.runtime, { aiVaultService: null }) - await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() - expect(f.vault).not.toHaveBeenCalled() -}) diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts index d4d179449ba5..4295014782f2 100644 --- a/src/relay/relay-runtime-services.ts +++ b/src/relay/relay-runtime-services.ts @@ -32,7 +32,6 @@ export class RelayRuntimeServices { readonly fsHandler: FsHandler readonly gitHandler: GitHandler readonly skillInstallHandler: SkillInstallHandler - private readonly responseStreams: GitResponseStreamRegistry private readonly aiVaultService: ReturnType | null private readonly sessionSearch: { dispose(): void } | null private readonly registeredHandlers: readonly unknown[] @@ -67,7 +66,6 @@ export class RelayRuntimeServices { // so two registries would hand out the same id, and only GitHandler routes the `git.responseAck` // credit every pump waits on. A second registry is not an option — see git-response-stream.ts. const responseStreams = new GitResponseStreamRegistry() - this.responseStreams = responseStreams this.fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams) const watchRegistry = this.fsHandler.getWatchRegistry() this.ptyHandler.setWorktreeRemovalCoordinator(watchRegistry) @@ -121,16 +119,7 @@ export class RelayRuntimeServices { this.registerRemoteCliRoutes() } - // Why: the handler work drain ends when a stream's metadata/sentinel is answered; the detached - // pumps and file descriptors behind it are only proven gone by these registry drains. async disposeOwnedProcesses(): Promise { - const failures: unknown[] = [] - const responses = this.responseStreams.disposeAllAndWait().catch((error: unknown) => { - failures.push(error) - }) - const fileStreams = this.fsHandler.disposeFileStreams().catch((error: unknown) => { - failures.push(error) - }) await this.skillInstallHandler.dispose().catch((error) => { relayLogLine( `[relay] Skill upload cleanup failed: ${error instanceof Error ? error.message : String(error)}` @@ -141,13 +130,6 @@ export class RelayRuntimeServices { `[relay] AI Vault sidecar shutdown failed: ${error instanceof Error ? error.message : String(error)}` ) }) - await responses - await fileStreams - // Why: an unclosed fd defers shutdown so the next attempt retries it; skill/AI Vault - // cleanup stays log-and-continue until the T2 lifecycle port. - if (failures.length > 0) { - throw new AggregateError(failures, 'relay_owned_process_shutdown_incomplete') - } } disposeHandlers(): void { From 91620bfc9e1792fd7b64aaa33797f9a032303f2f Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:05 -0700 Subject: [PATCH 24/26] Revert "refactor(runtime-rpc): extract the Node WebSocket lifecycle; opt-in pinned port (#24186)" This reverts commit a789233bbb14f872fc0d51c2058ff324a14ac43c. --- .../runtime/rpc/node-websocket-lifecycle.ts | 153 -------------- .../runtime/rpc/websocket-transport-limits.ts | 5 - src/main/runtime/rpc/ws-transport.test.ts | 41 +--- src/main/runtime/rpc/ws-transport.ts | 189 +++++++++++++----- .../runtime-rpc-required-ws-port.test.ts | 83 -------- .../runtime-rpc/runtime-rpc-lifecycle.ts | 9 +- .../runtime-rpc/runtime-rpc-pairing-types.ts | 2 - .../runtime/runtime-rpc/runtime-rpc-state.ts | 3 - 8 files changed, 146 insertions(+), 339 deletions(-) delete mode 100644 src/main/runtime/rpc/node-websocket-lifecycle.ts delete mode 100644 src/main/runtime/rpc/websocket-transport-limits.ts delete mode 100644 src/main/runtime/runtime-rpc-required-ws-port.test.ts diff --git a/src/main/runtime/rpc/node-websocket-lifecycle.ts b/src/main/runtime/rpc/node-websocket-lifecycle.ts deleted file mode 100644 index e37e38375dad..000000000000 --- a/src/main/runtime/rpc/node-websocket-lifecycle.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { Server as HttpServer } from 'node:http' -import type { Server as HttpsServer } from 'node:https' -import type { WebSocket, WebSocketServer } from 'ws' -import type { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat' - -type WebSocketMessagePayload = string | Uint8Array -export type WebSocketMessageHandler = { - bivarianceHack( - msg: WebSocketMessagePayload, - reply: (response: string) => void, - ws: WebSocket - ): void -}['bivarianceHack'] - -export type WebSocketConnectionCloseHandler = ( - clientId: string | null, - ws: WebSocket, - hasOtherConnections: boolean -) => void - -// Why: WS connections are long-lived and multiplex many RPCs by `id`; auth and dispatch are delegated to the message handler. -export function attachNodeWebSocketLifecycle(args: { - ws: WebSocket - heartbeat: RemoteRuntimeServerHeartbeat - preAuthTimeoutMs: number - preAuthTimers: WeakMap> - clientIds: Map - heartbeatConnections: Set - // Why: read lazily so a handler registered after start still reaches sockets accepted earlier. - getMessageHandler: () => WebSocketMessageHandler | null - getConnectionCloseHandler: () => WebSocketConnectionCloseHandler | null -}): void { - const { ws } = args - let finalized = false - const onPong = (): void => args.heartbeat.noteAlive(ws) - const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => { - // Why: any inbound frame counts as proof of life, so an actively-talking client isn't reaped mid-request. - args.heartbeat.noteAlive(ws) - const message = - typeof data === 'string' ? data : isBinary ? toBinaryPayload(data) : data.toString() - args.getMessageHandler()?.( - message, - (response) => { - // Why: mobile clients disconnect often; guard the write so we don't throw on a dead socket. - if (ws.readyState === ws.OPEN) { - ws.send(response) - } - }, - ws - ) - } - const finalize = (): void => { - if (finalized) { - return - } - finalized = true - ws.off('pong', onPong) - ws.off('message', onMessage) - ws.off('close', finalize) - ws.off('error', onError) - clearNodeWebSocketPreAuthTimer(ws, args.preAuthTimers) - args.heartbeatConnections.delete(ws) - if (args.heartbeatConnections.size === 0) { - args.heartbeat.stop() - } - const clientId = args.clientIds.get(ws) ?? null - args.clientIds.delete(ws) - const hasOtherConnections = - clientId !== null && Array.from(args.clientIds.values()).includes(clientId) - args.getConnectionCloseHandler()?.(clientId, ws, hasOtherConnections) - } - const onError = (): void => { - // Why: close isn't guaranteed after every error path; finalize here too so pre-auth E2EE state and connection ids can't leak. - finalize() - ws.close() - } - const preAuthTimer = setTimeout(() => { - if (!args.clientIds.has(ws)) { - // Why: a silent auto-ponging client would otherwise hold a finite mobile slot forever without starting the E2EE handshake. - ws.terminate() - } - }, args.preAuthTimeoutMs) - preAuthTimer.unref?.() - args.preAuthTimers.set(ws, preAuthTimer) - ws.on('pong', onPong) - ws.on('message', onMessage) - // Why: clean up connection-scoped state (e.g. mobile-fit overrides) so a dropped phone doesn't leave orphaned phone-fit on desktop. - ws.on('close', finalize) - ws.on('error', onError) - // Why: install lifecycle ownership before periodic heartbeat ticks can observe this socket. - args.heartbeatConnections.add(ws) - args.heartbeat.noteAlive(ws) - if (args.heartbeatConnections.size === 1) { - // Unauthenticated sockets are protected by the pre-auth timeout; heartbeat probes begin only - // after E2EE binds a client id, avoiding control frames during the handshake. - args.heartbeat.start(() => args.clientIds.keys()) - } -} - -function toBinaryPayload(data: Exclude): Uint8Array { - return Array.isArray(data) ? Buffer.concat(data) : new Uint8Array(data) -} - -export function clearNodeWebSocketPreAuthTimer( - ws: WebSocket, - preAuthTimers: WeakMap> -): void { - const timer = preAuthTimers.get(ws) - if (timer) { - clearTimeout(timer) - preAuthTimers.delete(ws) - } -} - -export async function stopNodeWebSocketTransport(args: { - wss: WebSocketServer | null - httpServer: HttpServer | HttpsServer | null - heartbeat: RemoteRuntimeServerHeartbeat - heartbeatConnections: Set -}): Promise { - args.heartbeat.stop() - args.heartbeatConnections.clear() - if (args.wss) { - for (const client of args.wss.clients) { - // Why: a half-open mobile socket may never answer a close frame, which keeps httpServer.close pending. - client.terminate() - } - args.wss.close() - } - const httpServer = args.httpServer - if (httpServer) { - await new Promise((resolve, reject) => { - httpServer.close((error) => { - if (error) { - reject(error) - return - } - resolve() - }) - // Why: idle keep-alive static-web connections would otherwise hold close() open. - httpServer.closeAllConnections() - }) - } -} - -// Why: force-terminate soon after the 1013 close since a half-open phone may never ack and would hold the descriptor past the WS cap; the 'error' listener absorbs a reset while closing. -export function rejectNodeWebSocketOverCapacity(ws: WebSocket): void { - ws.on('error', () => {}) - ws.close(1013, 'Maximum connections reached') - const terminateTimer = setTimeout(() => ws.terminate(), 1_000) - terminateTimer.unref?.() - ws.once('close', () => clearTimeout(terminateTimer)) -} diff --git a/src/main/runtime/rpc/websocket-transport-limits.ts b/src/main/runtime/rpc/websocket-transport-limits.ts deleted file mode 100644 index e2e0b3d16ce2..000000000000 --- a/src/main/runtime/rpc/websocket-transport-limits.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES = 1024 * 1024 -// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs. -export const WEBSOCKET_TRANSPORT_MAX_CONNECTIONS = 128 -// Why: bound pre-upgrade descriptor use above the WS cap so raw sockets can't grow without bound. -export const WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS = WEBSOCKET_TRANSPORT_MAX_CONNECTIONS * 2 diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 90be4229d422..45798a89528b 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -1,12 +1,10 @@ import { EventEmitter } from 'node:events' import { mkdtempSync } from 'node:fs' -import { connect } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, afterEach, vi } from 'vitest' import WebSocket from 'ws' import { WebSocketTransport } from './ws-transport' -import { rejectNodeWebSocketOverCapacity } from './node-websocket-lifecycle' import { loadOrCreateTlsCertificate } from '../tls-certificate' // Why: disable TLS verification for self-signed certs in tests. @@ -435,7 +433,9 @@ describe('WebSocketTransport', () => { vi.useFakeTimers() try { - rejectNodeWebSocketOverCapacity(serverSocket!) + ;(transport as unknown as { rejectOverCapacity(ws: WebSocket): void }).rejectOverCapacity( + serverSocket! + ) vi.advanceTimersByTime(1_000) } finally { vi.useRealTimers() @@ -455,24 +455,6 @@ describe('WebSocketTransport', () => { await transport.stop() }) - it('stops while an HTTP client holds an unanswered request open', async () => { - const transport = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) - transports.push(transport) - await transport.start() - const socket = connect(transport.resolvedPort, '127.0.0.1') - await new Promise((resolve) => socket.once('connect', () => resolve())) - socket.on('error', () => {}) - // Why: the server's automatic 100 Continue proves the request is parsed and in flight. - const continued = new Promise((resolve) => socket.once('data', () => resolve())) - socket.write( - 'POST /unanswered HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 1\r\nExpect: 100-continue\r\n\r\n' - ) - await continued - - await transport.stop() - socket.destroy() - }) - it('is safe to stop without starting', async () => { const { transport } = await createTransport() await transport.stop() @@ -687,23 +669,6 @@ describe('WebSocketTransport', () => { expect(transport.resolvedPort).toBe(fallbackPort) }) - it('fails closed when a managed tunnel requires the configured port', async () => { - const preferredHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) - transports.push(preferredHolder) - await preferredHolder.start() - const transport = new WebSocketTransport({ - host: '127.0.0.1', - port: preferredHolder.resolvedPort, - fallbackPort: await reserveFreePort(), - preferPinnedPort: true, - strictPort: true - }) - transports.push(transport) - - await expect(transport.start()).rejects.toMatchObject({ code: 'EADDRINUSE' }) - expect(transport.resolvedHost).toBeNull() - }) - it('binds the preferred port when the persisted fallback is taken', async () => { const fallbackHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) transports.push(fallbackHolder) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 81e76b1bb09b..3ba8a17fe607 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -4,22 +4,22 @@ import { createServer as createHttpServer, type Server as HttpServer } from 'nod import { WebSocketServer, type WebSocket } from 'ws' import type { RpcTransport } from './transport' import { createStaticWebClientHandler } from './static-web-client-handler' -import { - attachNodeWebSocketLifecycle, - clearNodeWebSocketPreAuthTimer, - rejectNodeWebSocketOverCapacity, - stopNodeWebSocketTransport, - type WebSocketConnectionCloseHandler, - type WebSocketMessageHandler -} from './node-websocket-lifecycle' import { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat' -import { - WEBSOCKET_TRANSPORT_MAX_CONNECTIONS, - WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES, - WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS -} from './websocket-transport-limits' +const MAX_WS_MESSAGE_BYTES = 1024 * 1024 +// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs. +const MAX_WS_CONNECTIONS = 128 +// Why: bound pre-upgrade descriptor use above the WS cap so raw sockets can't grow without bound. +const MAX_TCP_CONNECTIONS = MAX_WS_CONNECTIONS * 2 const PRE_AUTH_TIMEOUT_MS = 10_000 +type WebSocketMessagePayload = string | Uint8Array +type WebSocketMessageHandler = { + bivarianceHack( + msg: WebSocketMessagePayload, + reply: (response: string) => void, + ws: WebSocket + ): void +}['bivarianceHack'] // Why: mobile clients background-suspend sockets with no TCP FIN, leaving half-opens that otherwise only the OS keepalive (~2h) reaps; a 15s ping/pong sweep bounds that to ~60s (clients auto-pong per RFC 6455), since a reap needs consecutive unanswered probes rather than one (STA-3320). const HEARTBEAT_INTERVAL_MS = 15_000 @@ -41,8 +41,6 @@ export type WebSocketTransportOptions = { fallbackPort?: number // Why: serve --port clients dial the pinned port; prefer it first so a stale fallback can't steal the pin (issue #8535). Default keeps fallback-first (STA-1511). preferPinnedPort?: boolean - // Why: managed SSH tunnels dial one configured remote port and cannot follow a fallback. - strictPort?: boolean } export class WebSocketTransport implements RpcTransport { @@ -55,11 +53,12 @@ export class WebSocketTransport implements RpcTransport { private readonly staticRoot: string | undefined private readonly fallbackPort: number | undefined private readonly preferPinnedPort: boolean - private readonly strictPort: boolean private httpServer: HttpsServer | HttpServer | null = null private wss: WebSocketServer | null = null private messageHandler: WebSocketMessageHandler | null = null - private connectionCloseHandler: WebSocketConnectionCloseHandler | null = null + private connectionCloseHandler: + | ((clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void) + | null = null // Why: maps each socket to its authenticated clientId so close can report which device disconnected. private wsClientIds = new Map() private heartbeatConnections = new Set() @@ -75,8 +74,7 @@ export class WebSocketTransport implements RpcTransport { preAuthTimeoutMs, staticRoot, fallbackPort, - preferPinnedPort, - strictPort + preferPinnedPort }: WebSocketTransportOptions) { this.host = host this.port = port @@ -85,13 +83,12 @@ export class WebSocketTransport implements RpcTransport { this.heartbeat = new RemoteRuntimeServerHeartbeat( heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS, heartbeatNow, - WEBSOCKET_TRANSPORT_MAX_CONNECTIONS + MAX_WS_CONNECTIONS ) this.preAuthTimeoutMs = preAuthTimeoutMs ?? PRE_AUTH_TIMEOUT_MS this.staticRoot = staticRoot this.fallbackPort = fallbackPort this.preferPinnedPort = preferPinnedPort === true - this.strictPort = strictPort === true } onMessage(handler: WebSocketMessageHandler): void { @@ -99,13 +96,15 @@ export class WebSocketTransport implements RpcTransport { } // Why: pass the closing `ws` and whether other sockets share its deviceToken, so client-scoped teardown fires only on the last disconnect. - onConnectionClose(handler: WebSocketConnectionCloseHandler): void { + onConnectionClose( + handler: (clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void + ): void { this.connectionCloseHandler = handler } setClientId(ws: WebSocket, clientId: string): void { this.wsClientIds.set(ws, clientId) - clearNodeWebSocketPreAuthTimer(ws, this.preAuthTimers) + this.clearPreAuthTimer(ws) } terminateClientConnections(clientId: string): number { @@ -138,10 +137,6 @@ export class WebSocketTransport implements RpcTransport { if (this.wss) { return } - if (this.strictPort) { - await this.tryListen(this.port) - return - } // Why: bind a persisted fallback first so devices paired to it aren't stranded (STA-1511); serve --port flips to pinned-first (issue #8535); on failure each candidate falls through to OS-assigned port 0. const persistedFallbackPort = @@ -197,16 +192,16 @@ export class WebSocketTransport implements RpcTransport { }) // Why: the WS cap applies only post-upgrade; a separate TCP cap bounds raw/pre-upgrade descriptor use. - httpServer.maxConnections = WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS + httpServer.maxConnections = MAX_TCP_CONNECTIONS const wss = new WebSocketServer({ server: httpServer, - maxPayload: WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES + maxPayload: MAX_WS_MESSAGE_BYTES }) wss.on('connection', (ws) => { - if (wss.clients.size > WEBSOCKET_TRANSPORT_MAX_CONNECTIONS) { - rejectNodeWebSocketOverCapacity(ws) + if (wss.clients.size > MAX_WS_CONNECTIONS) { + this.rejectOverCapacity(ws) return } this.handleConnection(ws) @@ -216,30 +211,130 @@ export class WebSocketTransport implements RpcTransport { this.wss = wss } + // Why: force-terminate soon after the 1013 close since a half-open phone may never ack and would hold the descriptor past the WS cap; the 'error' listener absorbs a reset while closing. + private rejectOverCapacity(ws: WebSocket): void { + ws.on('error', () => {}) + ws.close(1013, 'Maximum connections reached') + const terminateTimer = setTimeout(() => ws.terminate(), 1_000) + terminateTimer.unref?.() + ws.once('close', () => clearTimeout(terminateTimer)) + } + async stop(): Promise { const wss = this.wss const httpServer = this.httpServer this.wss = null this.httpServer = null - await stopNodeWebSocketTransport({ - wss, - httpServer, - heartbeat: this.heartbeat, - heartbeatConnections: this.heartbeatConnections - }) + this.heartbeat.stop() + this.heartbeatConnections.clear() + + if (wss) { + for (const client of wss.clients) { + // Why: a half-open mobile socket may never answer a close frame, which keeps httpServer.close pending. + client.terminate() + } + wss.close() + } + + if (httpServer) { + await new Promise((resolve, reject) => { + httpServer.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + } } + // Why: WS connections are long-lived and multiplex many RPCs by `id`; auth and dispatch are delegated to the message handler. private handleConnection(ws: WebSocket): void { - attachNodeWebSocketLifecycle({ - ws, - heartbeat: this.heartbeat, - preAuthTimeoutMs: this.preAuthTimeoutMs, - preAuthTimers: this.preAuthTimers, - clientIds: this.wsClientIds, - heartbeatConnections: this.heartbeatConnections, - getMessageHandler: () => this.messageHandler, - getConnectionCloseHandler: () => this.connectionCloseHandler - }) + let finalized = false + const onPong = (): void => { + this.heartbeat.noteAlive(ws) + } + const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => { + // Why: any inbound frame counts as proof of life, so an actively-talking client isn't reaped mid-request. + this.heartbeat.noteAlive(ws) + const msg = + typeof data === 'string' + ? data + : isBinary + ? new Uint8Array(data as Buffer) + : data.toString() + this.messageHandler?.( + msg, + (response) => { + // Why: mobile clients disconnect often; guard the write so we don't throw on a dead socket. + if (ws.readyState === ws.OPEN) { + ws.send(response) + } + }, + ws + ) + } + const onError = (): void => { + // Why: close isn't guaranteed after every error path; finalize here too so pre-auth E2EE state and connection ids can't leak. + finalizeConnection() + ws.close() + } + const finalizeConnection = (): void => { + if (finalized) { + return + } + finalized = true + ws.off('pong', onPong) + ws.off('message', onMessage) + ws.off('close', finalizeConnection) + ws.off('error', onError) + this.clearPreAuthTimer(ws) + this.heartbeatConnections.delete(ws) + if (this.heartbeatConnections.size === 0) { + this.heartbeat.stop() + } + const clientId = this.wsClientIds.get(ws) ?? null + this.wsClientIds.delete(ws) + const hasOtherConnections = + clientId !== null && Array.from(this.wsClientIds.values()).includes(clientId) + this.connectionCloseHandler?.(clientId, ws, hasOtherConnections) + } + + const preAuthTimer = setTimeout(() => { + if (!this.wsClientIds.has(ws)) { + // Why: a silent auto-ponging client would otherwise hold a finite mobile slot forever without starting the E2EE handshake. + ws.terminate() + } + }, this.preAuthTimeoutMs) + if (typeof preAuthTimer.unref === 'function') { + preAuthTimer.unref() + } + this.preAuthTimers.set(ws, preAuthTimer) + + ws.on('pong', onPong) + ws.on('message', onMessage) + + // Why: clean up connection-scoped state (e.g. mobile-fit overrides) so a dropped phone doesn't leave orphaned phone-fit on desktop. + ws.on('close', finalizeConnection) + ws.on('error', onError) + + // Why: install lifecycle ownership before periodic heartbeat ticks can observe this socket. + this.heartbeatConnections.add(ws) + this.heartbeat.noteAlive(ws) + if (this.heartbeatConnections.size === 1) { + // Unauthenticated sockets are protected by the pre-auth timeout; heartbeat probes begin only + // after E2EE binds a client id, avoiding control frames during the handshake. + this.heartbeat.start(() => this.wsClientIds.keys()) + } + } + + private clearPreAuthTimer(ws: WebSocket): void { + const timer = this.preAuthTimers.get(ws) + if (timer) { + clearTimeout(timer) + this.preAuthTimers.delete(ws) + } } } diff --git a/src/main/runtime/runtime-rpc-required-ws-port.test.ts b/src/main/runtime/runtime-rpc-required-ws-port.test.ts deleted file mode 100644 index 5a264fcc0c00..000000000000 --- a/src/main/runtime/runtime-rpc-required-ws-port.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { mkdtempSync, readdirSync } from 'node:fs' -import { createServer, type Server } from 'node:net' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' -import { OrcaRuntimeRpcServer } from './runtime-rpc' -import { readWsFallbackPort } from './rpc/ws-fallback-port-store' - -vi.mock('../git/worktree', () => ({ - listWorktrees: vi.fn().mockResolvedValue([]), - listWorktreesStrict: vi.fn().mockResolvedValue([]) -})) - -const holders: Server[] = [] - -afterEach(async () => { - await Promise.all( - holders.splice(0).map((holder) => new Promise((resolve) => holder.close(() => resolve()))) - ) -}) - -async function occupyLoopbackPort(): Promise { - const holder = createServer() - holders.push(holder) - await new Promise((resolve) => holder.listen(0, '127.0.0.1', resolve)) - const address = holder.address() - if (!address || typeof address === 'string') { - throw new Error('holder did not bind a TCP port') - } - return address.port -} - -function boundWsPort(server: OrcaRuntimeRpcServer): number | null { - const endpoint = server.getWebSocketEndpoint() - return endpoint ? Number(new URL(endpoint).port) : null -} - -describe('OrcaRuntimeRpcServer required WebSocket port', () => { - it('fails startup instead of falling back when the required port is taken', async () => { - const port = await occupyLoopbackPort() - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) - const server = new OrcaRuntimeRpcServer({ - runtime: new OrcaRuntimeService(), - userDataPath, - enableWebSocket: true, - wsPort: port, - preferPinnedWsPort: true, - requirePinnedWsPort: true - }) - - try { - await expect(server.start()).rejects.toMatchObject({ code: 'EADDRINUSE' }) - expect(boundWsPort(server)).toBeNull() - expect(readWsFallbackPort(userDataPath)).toBeUndefined() - // Why: the failed start must also release the Unix socket it opened first, or the endpoint leaks. - expect(readdirSync(userDataPath).filter((name) => name.endsWith('.sock'))).toEqual([]) - } finally { - await server.stop() - } - }) - - it('still falls back for a pinned but not required port', async () => { - const port = await occupyLoopbackPort() - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) - const server = new OrcaRuntimeRpcServer({ - runtime: new OrcaRuntimeService(), - userDataPath, - enableWebSocket: true, - wsPort: port, - preferPinnedWsPort: true - }) - - await server.start() - try { - const resolvedPort = boundWsPort(server) - expect(resolvedPort).not.toBe(port) - expect(readWsFallbackPort(userDataPath)).toBe(resolvedPort) - } finally { - await server.stop() - } - }) -}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index 4c595b3bbabf..9cfe31251b6e 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -90,9 +90,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { port: this.wsPort, preferPinnedPort: this.preferPinnedWsPort, // Why: stable fallback port across restarts keeps paired devices' endpoints valid (STA-1511); wsPort 0 = random (E2E). - ...(this.wsPort !== 0 && !this.requirePinnedWsPort - ? { fallbackPort: readWsFallbackPort(this.userDataPath) } - : {}) + ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) }) if (this.wsPort !== 0 && transport.resolvedPort !== this.wsPort) { writeWsFallbackPort(this.userDataPath, transport.resolvedPort) @@ -100,10 +98,6 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { activeTransports.push(transport) transportsMeta.push({ kind: 'websocket', endpoint }) } catch (error) { - if (this.requirePinnedWsPort) { - await socketTransport.stop().catch(() => {}) - throw error - } // Why: WebSocket transport is supplementary; on failure (e.g. port in use) continue with Unix socket only. console.error('[runtime] Failed to start WebSocket transport:', error) this.mobileSocketWiring = null @@ -180,7 +174,6 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { host: options.host, port: options.port, staticRoot: this.webClientRoot, - ...(this.requirePinnedWsPort ? { strictPort: true } : {}), ...(options.fallbackPort !== undefined ? { fallbackPort: options.fallbackPort } : {}), ...(options.preferPinnedPort ? { preferPinnedPort: true } : {}) }) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts index da517dd9d925..4923dec1dec1 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts @@ -36,8 +36,6 @@ export type OrcaRuntimeRpcServerOptions = { wsPort?: number // Why: true when the caller pinned a port (`orca serve --port`) so bind order prefers it over a stale STA-1511 fallback (#8535). preferPinnedWsPort?: boolean - // Why: a managed SSH tunnel forwards exactly this port, so a fallback bind must fail startup instead. - requirePinnedWsPort?: boolean // Why: STA-2370 — bind the WS listener to all interfaces at startup instead of loopback-until-paired. // Only `orca serve` (explicit remote opt-in) and E2E set this; the desktop app widens lazily on pairing. exposeNetworkByDefault?: boolean diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index 20ddbf70e38d..e7f56ceed13c 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -38,7 +38,6 @@ export class RuntimeRpcState { protected readonly enableWebSocket: boolean protected readonly wsPort: number protected readonly preferPinnedWsPort: boolean - protected readonly requirePinnedWsPort: boolean protected readonly exposeNetworkByDefault: boolean protected readonly pinnedBindHost: string | null protected readonly webClientRoot: string | undefined @@ -101,7 +100,6 @@ export class RuntimeRpcState { enableWebSocket = false, wsPort = DEFAULT_WS_PORT, preferPinnedWsPort = false, - requirePinnedWsPort = false, exposeNetworkByDefault = false, pinnedBindHost, webClientRoot, @@ -118,7 +116,6 @@ export class RuntimeRpcState { this.enableWebSocket = enableWebSocket this.wsPort = wsPort this.preferPinnedWsPort = preferPinnedWsPort - this.requirePinnedWsPort = requirePinnedWsPort this.exposeNetworkByDefault = exposeNetworkByDefault this.pinnedBindHost = pinnedBindHost ?? null this.webClientRoot = webClientRoot From db0e0ebc6dbbb5421b0e5f760bd199f5de142b96 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:05 -0700 Subject: [PATCH 25/26] Revert "feat(relay): route relay handlers through work admission; producer publication drain (#24181)" This reverts commit 0b812bd69856f9f2337dd54d7f3b9ceedff24cc9. --- src/relay/dispatcher-client-state.ts | 21 +- src/relay/dispatcher-client-writer.ts | 4 - src/relay/dispatcher-contract.ts | 2 - src/relay/dispatcher-frame-codec.ts | 12 +- .../dispatcher-notification-publication.ts | 47 +---- .../dispatcher-producer-settlement.test.ts | 106 ---------- src/relay/dispatcher-producer-transport.ts | 25 +-- src/relay/dispatcher-rpc-routing.ts | 2 - src/relay/dispatcher-work-drain.test.ts | 184 ----------------- .../relay-producer-publication-drain.test.ts | 188 ------------------ src/relay/relay-producer-publication-drain.ts | 50 ----- src/relay/relay-work-admission.ts | 30 +-- src/shared/relay-work-drain-contract.ts | 3 - 13 files changed, 38 insertions(+), 636 deletions(-) delete mode 100644 src/relay/dispatcher-producer-settlement.test.ts delete mode 100644 src/relay/dispatcher-work-drain.test.ts delete mode 100644 src/relay/relay-producer-publication-drain.test.ts delete mode 100644 src/relay/relay-producer-publication-drain.ts diff --git a/src/relay/dispatcher-client-state.ts b/src/relay/dispatcher-client-state.ts index 80d1990085ed..710a704e4d8e 100644 --- a/src/relay/dispatcher-client-state.ts +++ b/src/relay/dispatcher-client-state.ts @@ -1,6 +1,5 @@ import type { DecodedFrame, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse } from './protocol' import { ClientRequestAborts } from './client-request-aborts' -import { RelayWorkAdmission } from './relay-work-admission' import type { PtyConsumerCloseCause } from '../shared/pty-consumer-session-contract' import { LegacyRelayPublicationLedger, @@ -20,12 +19,10 @@ import type { PtyDataPublicationAdmission, RelayClient, RelayClientSessionIdentity, - RelayClientSourceOptions, - RequestContext + RelayClientSourceOptions } from './dispatcher-contract' export abstract class RelayDispatcherClientState { - protected readonly workAdmission = new RelayWorkAdmission() protected readonly primaryClient: RelayClient protected readonly clients = new Map() protected requestHandlers = new Map() @@ -60,17 +57,7 @@ export abstract class RelayDispatcherClientState { } onRequest(method: string, handler: MethodHandler): void { - this.requestHandlers.set(method, (params, context) => - this.workAdmission.run(method, context, () => handler(params, context)) - ) - } - - beginWorkDrain(exclude?: RequestContext): Promise { - return this.workAdmission.beginDrain(exclude) - } - - assertActiveWorkContext(context: RequestContext): void { - this.workAdmission.assertActiveContext(context) + this.requestHandlers.set(method, handler) } // Why it throws: this is a single slot, so a second registration silently shadows the @@ -81,9 +68,7 @@ export abstract class RelayDispatcherClientState { if (this.notificationHandlers.has(method)) { throw new Error(`Notification handler for ${method} is already registered`) } - this.notificationHandlers.set(method, (params, context) => - this.workAdmission.runNotification(method, context, () => handler(params, context)) - ) + this.notificationHandlers.set(method, handler) } onClientDetached(listener: (clientId: number, cause: PtyConsumerCloseCause) => void): () => void { diff --git a/src/relay/dispatcher-client-writer.ts b/src/relay/dispatcher-client-writer.ts index 42feaa682189..514ee47b8da6 100644 --- a/src/relay/dispatcher-client-writer.ts +++ b/src/relay/dispatcher-client-writer.ts @@ -56,10 +56,6 @@ export class DispatcherClientWriter { return this.admission.retainedProducerBytes } - get supportsWriteCallback(): boolean { - return this.sink.supportsWriteCallback - } - get producerFrameCapacity(): number { return this.sink.producerFrameCapacity } diff --git a/src/relay/dispatcher-contract.ts b/src/relay/dispatcher-contract.ts index 679d4657e62b..23c62431d2c1 100644 --- a/src/relay/dispatcher-contract.ts +++ b/src/relay/dispatcher-contract.ts @@ -9,8 +9,6 @@ import type { DispatcherClientWriter, SinkWriteSettlement } from './dispatcher-c export type RequestContext = { clientId: number - /** Monotonic transport generation; optional for in-process callers. */ - transportGeneration?: number isStale: () => boolean signal?: AbortSignal sessionIdentity?: RelayClientSessionIdentity diff --git a/src/relay/dispatcher-frame-codec.ts b/src/relay/dispatcher-frame-codec.ts index ffa67d2b12ce..5a25fa1100f2 100644 --- a/src/relay/dispatcher-frame-codec.ts +++ b/src/relay/dispatcher-frame-codec.ts @@ -85,8 +85,7 @@ export abstract class RelayDispatcherFrameCodec extends RelayDispatcherCapacityS frame: PreparedRelayFrame, lane: DispatcherWriterLane, onSettled: (result: SinkWriteSettlement) => void = () => {}, - controlOverflow: 'close-client' | 'reject' = 'close-client', - publicationAdmission?: () => boolean + controlOverflow: 'close-client' | 'reject' = 'close-client' ): boolean { if (this.disposed || client.closed) { return false @@ -96,12 +95,9 @@ export abstract class RelayDispatcherFrameCodec extends RelayDispatcherCapacityS return encodePreparedJsonRpcFrame(frame.payload, seq, client.highestReceivedSeq) } const admissionParams = frame.ptyDataAdmissionParams - const isStillAdmitted = - admissionParams || publicationAdmission - ? () => - (publicationAdmission?.() ?? true) && - (!admissionParams || this.admitsPtyDataPublication(client.id, admissionParams)) - : undefined + const isStillAdmitted = admissionParams + ? () => this.admitsPtyDataPublication(client.id, admissionParams) + : undefined return client.writer.enqueue( lane, encode, diff --git a/src/relay/dispatcher-notification-publication.ts b/src/relay/dispatcher-notification-publication.ts index ca09a5038147..ab0851b42033 100644 --- a/src/relay/dispatcher-notification-publication.ts +++ b/src/relay/dispatcher-notification-publication.ts @@ -1,5 +1,4 @@ import type { SinkWriteSettlement } from './dispatcher-client-writer' -import { onceDispatcherWriterSettlement } from './dispatcher-writer-admission' import type { JsonRpcNotification } from './protocol' import { DROPPED_NOTIFICATION_LOG_KEY_LIMIT, @@ -9,16 +8,6 @@ import { import { RelayDispatcherPtyPublication } from './dispatcher-pty-publication' export abstract class RelayDispatcherNotificationPublication extends RelayDispatcherPtyPublication { - assertSettledProducerTransport(clientId: number, generation: number): void { - const client = this.clients.get(clientId) - if (this.disposed || !client || client.closed || client.generation !== generation) { - throw new Error('relay_producer_transport_unverifiable') - } - if (!client.writer.supportsWriteCallback) { - throw new Error('relay_producer_write_callback_required') - } - } - notify(method: string, params?: Record): void { if (this.disposed) { return @@ -61,24 +50,14 @@ export abstract class RelayDispatcherNotificationPublication extends RelayDispat params?: Record, // Why: a caller that recovers from rejection itself (the watcher emitter re-sends the batch in // chunks) would otherwise log "Dropped" for a frame it goes on to deliver in full. - options?: { - logDrop?: boolean - onSettled?: (result: SinkWriteSettlement) => void - settledTransportGeneration?: number - isStillAdmitted?: () => boolean - } + options?: { logDrop?: boolean } ): boolean { - const settle = onceDispatcherWriterSettlement(options?.onSettled ?? (() => {})) - const refuse = (message: string): false => { - settle({ ok: false, error: new Error(message) }) - return false - } if (this.disposed) { - return refuse('Relay dispatcher is disposed') + return false } const client = this.clients.get(clientId) if (!client || client.closed) { - return refuse('Relay client is not connected') + return false } const msg: JsonRpcNotification = { jsonrpc: '2.0', @@ -86,29 +65,17 @@ export abstract class RelayDispatcherNotificationPublication extends RelayDispat ...(params !== undefined ? { params } : {}) } if (method === 'pty.data' && !this.admitsPtyDataPublication(client.id, params ?? {})) { - return refuse('Relay PTY publication is not admitted') - } - let frame: PreparedRelayFrame - try { - if (options?.settledTransportGeneration !== undefined) { - this.assertSettledProducerTransport(clientId, options.settledTransportGeneration) - } - frame = this.prepareFrame(msg) - if (options?.settledTransportGeneration !== undefined) { - this.assertSettledProducerTransport(clientId, options.settledTransportGeneration) - } - } catch (error) { - settle({ ok: false, error: error instanceof Error ? error : new Error(String(error)) }) - throw error + return false } - if (this.publishPreparedToClient(client, frame, 'ordinary', settle, options?.isStillAdmitted)) { + const frame = this.prepareFrame(msg) + if (this.publishPreparedToClient(client, frame, 'ordinary')) { return true } // Why: same diagnostics as notify() — a producer that drops here must not do so silently. if (options?.logDrop !== false) { this.logDroppedProducerNotification(client, method, frame.frameBytes) } - return refuse('Relay producer publication was not accepted') + return false } notifyClient(clientId: number, method: string, params?: Record): void { diff --git a/src/relay/dispatcher-producer-settlement.test.ts b/src/relay/dispatcher-producer-settlement.test.ts deleted file mode 100644 index 2c04f2cb6f75..000000000000 --- a/src/relay/dispatcher-producer-settlement.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' - -describe('producer notification settlement', () => { - const dispatchers: RelayDispatcher[] = [] - const setup = (highWaterMark?: number) => { - const writes: ((result: SinkWriteSettlement) => void)[] = [] - const dispatcher = new RelayDispatcher( - (_bytes, settled) => { - writes.push(settled) - return true - }, - { supportsWriteCallback: true, writableHighWaterMark: () => highWaterMark ?? Infinity } - ) - dispatchers.push(dispatcher) - return { dispatcher, writes } - } - afterEach(() => { - for (const dispatcher of dispatchers.splice(0)) { - dispatcher.dispose() - } - }) - - it('waits for sink settlement rather than enqueue acceptance and settles once', () => { - const { dispatcher, writes } = setup() - const onSettled = vi.fn() - expect(dispatcher.publishProducerNotification(1, 'tunnel.frame', {}, { onSettled })).toBe(true) - expect(onSettled).not.toHaveBeenCalled() - writes[0]({ ok: true }) - writes[0]({ ok: false, error: new Error('late duplicate') }) - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: true }) - }) - - it('retains sink failure and cannot turn disposal into successful publication', () => { - const { dispatcher, writes } = setup() - const onSettled = vi.fn() - dispatcher.publishProducerNotification(1, 'tunnel.frame', {}, { onSettled }) - const error = new Error('write failed') - writes[0]({ ok: false, error }) - dispatcher.dispose() - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error }) - }) - - it('fails outstanding publication on disposal even if a late write succeeds', () => { - const { dispatcher, writes } = setup() - const onSettled = vi.fn() - dispatcher.publishProducerNotification(1, 'tunnel.frame', {}, { onSettled }) - dispatcher.dispose() - writes[0]({ ok: true }) - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error: expect.any(Error) }) - }) - - it('reports bounded producer refusal without closing the client or taking the control lane', () => { - const { dispatcher, writes } = setup(16384) - const onSettled = vi.fn() - const detached = vi.fn() - dispatcher.onClientDetached(detached) - expect( - dispatcher.publishProducerNotification( - 1, - 'tunnel.frame', - { data: 'x'.repeat(20000) }, - { - onSettled, - logDrop: false - } - ) - ).toBe(false) - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error: expect.any(Error) }) - expect(writes).toHaveLength(0) - expect(detached).not.toHaveBeenCalled() - expect(dispatcher.publishProducerNotification(1, 'tunnel.frame', {})).toBe(true) - }) - - it.each(['missing', 'disposed', 'pty-admission'] as const)('settles %s refusal', (reason) => { - const { dispatcher, writes } = setup() - const onSettled = vi.fn() - if (reason === 'disposed') { - dispatcher.dispose() - } - if (reason === 'pty-admission') { - dispatcher.registerPtyDataPublicationAdmission(() => false) - } - expect( - dispatcher.publishProducerNotification( - reason === 'missing' ? 999 : 1, - reason === 'pty-admission' ? 'pty.data' : 'tunnel.frame', - {}, - { onSettled } - ) - ).toBe(false) - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error: expect.any(Error) }) - expect(writes).toHaveLength(0) - }) - - it('settles serialization failure while preserving the existing throw contract', () => { - const { dispatcher } = setup() - const onSettled = vi.fn() - const params: Record = {} - params.circular = params - expect(() => - dispatcher.publishProducerNotification(1, 'tunnel.frame', params, { onSettled }) - ).toThrow() - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error: expect.any(Error) }) - }) -}) diff --git a/src/relay/dispatcher-producer-transport.ts b/src/relay/dispatcher-producer-transport.ts index 5ef9717108fd..089a69577d1e 100644 --- a/src/relay/dispatcher-producer-transport.ts +++ b/src/relay/dispatcher-producer-transport.ts @@ -83,8 +83,7 @@ export abstract class RelayDispatcherProducerTransport extends RelayDispatcherRp client: RelayClient, frame: PreparedRelayFrame, lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', - onSettled: (result: SinkWriteSettlement) => void = () => {}, - isStillAdmitted?: () => boolean + onSettled: (result: SinkWriteSettlement) => void = () => {} ): boolean { const bytes = frame.frameBytes const fixedBlocked = @@ -97,7 +96,7 @@ export abstract class RelayDispatcherProducerTransport extends RelayDispatcherRp if (!leases) { return false } - return this.enqueueLeasedFrame(client, frame, lane, leases[0], onSettled, isStillAdmitted) + return this.enqueueLeasedFrame(client, frame, lane, leases[0], onSettled) } protected publishBulkWhenAvailable( @@ -149,21 +148,13 @@ export abstract class RelayDispatcherProducerTransport extends RelayDispatcherRp frame: PreparedRelayFrame, lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', lease: LegacyPublicationLease, - onSettled: (result: SinkWriteSettlement) => void = () => {}, - isStillAdmitted?: () => boolean + onSettled: (result: SinkWriteSettlement) => void = () => {} ): boolean { - const accepted = this.enqueuePreparedFrame( - client, - frame, - lane, - (result) => { - lease.release() - onSettled(result) - this.notifyLegacyCapacityIfLow() - }, - undefined, - isStillAdmitted - ) + const accepted = this.enqueuePreparedFrame(client, frame, lane, (result) => { + lease.release() + onSettled(result) + this.notifyLegacyCapacityIfLow() + }) if (!accepted) { lease.release() this.notifyLegacyCapacityIfLow() diff --git a/src/relay/dispatcher-rpc-routing.ts b/src/relay/dispatcher-rpc-routing.ts index 37689654a0da..a6e6c24190cc 100644 --- a/src/relay/dispatcher-rpc-routing.ts +++ b/src/relay/dispatcher-rpc-routing.ts @@ -104,7 +104,6 @@ export abstract class RelayDispatcherRpcRouting extends RelayDispatcherFrameCode } const context: RequestContext = { clientId: client.id, - transportGeneration: gen, isStale: () => client.generation !== gen || !this.clients.has(client.id) || abortController.signal.aborted, signal: abortController.signal, @@ -180,7 +179,6 @@ export abstract class RelayDispatcherRpcRouting extends RelayDispatcherFrameCode const gen = client.generation handler(notif.params ?? {}, { clientId: client.id, - transportGeneration: gen, isStale: () => client.generation !== gen || !this.clients.has(client.id), sessionIdentity: client.sessionIdentity, onResponseSettled: () => { diff --git a/src/relay/dispatcher-work-drain.test.ts b/src/relay/dispatcher-work-drain.test.ts deleted file mode 100644 index 8724dbabec1a..000000000000 --- a/src/relay/dispatcher-work-drain.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { RelayDispatcher } from './dispatcher' -import type { RequestContext } from './dispatcher' -import { - encodeJsonRpcFrame, - type JsonRpcRequest, - type JsonRpcResponse, - type JsonRpcNotification -} from './protocol' - -const dispatchers: RelayDispatcher[] = [] - -afterEach(() => { - for (const dispatcher of dispatchers.splice(0)) { - dispatcher.dispose() - } -}) -function fixture() { - const responses: Record[] = [] - const dispatcher = new RelayDispatcher((frame) => { - const length = frame.readUInt32BE(9) - responses.push(JSON.parse(frame.subarray(13, 13 + length).toString())) - return true - }) - dispatchers.push(dispatcher) - let sequence = 0 - const send = ( - message: - | Omit - | Omit - | Omit - ) => dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', ...message }, ++sequence, 0)) - return { dispatcher, responses, send } -} - -it('rejects new mutations over the wire while waiting for uncancellable admitted work', async () => { - const f = fixture() - const pending = Promise.withResolvers() - const mutation = vi.fn(() => pending.promise) - f.dispatcher.onRequest('fs.writeFile', mutation) - f.send({ id: 1, method: 'fs.writeFile' }) - const drained = vi.fn() - const drain = f.dispatcher.beginWorkDrain().then(drained) - f.send({ id: 2, method: 'fs.writeFile' }) - await vi.waitFor(() => - expect(f.responses).toContainEqual( - expect.objectContaining({ - id: 2, - error: expect.objectContaining({ message: 'relay_work_admission_closed' }) - }) - ) - ) - expect(mutation).toHaveBeenCalledOnce() - expect(drained).not.toHaveBeenCalled() - pending.resolve() - await drain -}) - -it('excludes only the initiating request from its own drain', async () => { - const f = fixture() - const work = Promise.withResolvers() - f.dispatcher.onRequest('fs.writeFile', () => work.promise) - const drained = vi.fn() - f.dispatcher.onRequest('test.drain', async (_params, context) => { - await f.dispatcher.beginWorkDrain(context) - drained() - return 'drained' - }) - f.send({ id: 1, method: 'fs.writeFile' }) - f.send({ id: 2, method: 'test.drain' }) - await new Promise((resolve) => setImmediate(resolve)) - expect(drained).not.toHaveBeenCalled() - work.resolve() - await vi.waitFor(() => - expect(f.responses).toContainEqual({ jsonrpc: '2.0', id: 2, result: 'drained' }) - ) -}) - -it('rejects copied and settled initiators without closing admission', async () => { - const f = fixture() - let actual: RequestContext | undefined - f.dispatcher.onRequest('test.capture', async (_params, context) => { - actual = context - await expect(f.dispatcher.beginWorkDrain({ ...context })).rejects.toThrow( - 'relay_work_drain_context_not_active' - ) - return 'captured' - }) - f.send({ id: 1, method: 'test.capture' }) - await vi.waitFor(() => expect(f.responses.some((response) => response.id === 1)).toBe(true)) - expect(() => f.dispatcher.assertActiveWorkContext(actual!)).toThrow( - 'relay_work_drain_context_not_active' - ) - const mutate = vi.fn(async () => 'still open') - f.dispatcher.onRequest('fs.writeFile', mutate) - f.send({ id: 2, method: 'fs.writeFile' }) - await vi.waitFor(() => expect(mutate).toHaveBeenCalledOnce()) -}) - -it('stamps the transport generation on request and notification contexts', async () => { - const f = fixture() - const seen: (number | undefined)[] = [] - f.dispatcher.onRequest('test.request', async (_params, context) => { - seen.push(context.transportGeneration) - return 'ok' - }) - f.dispatcher.onNotification('test.notify', (_params, context) => { - seen.push(context.transportGeneration) - }) - f.send({ id: 1, method: 'test.request' }) - f.send({ method: 'test.notify' }) - await vi.waitFor(() => expect(seen).toHaveLength(2)) - expect(seen[0]).toEqual(expect.any(Number)) - expect(seen[1]).toBe(seen[0]) -}) - -it('preserves ACK notifications and cancellation during drain without admitting new notifications', async () => { - const f = fixture() - const pending = Promise.withResolvers() - let signal: AbortSignal | undefined - f.dispatcher.onRequest('git.diff', (_params, context) => { - signal = context.signal - return pending.promise - }) - const write = vi.fn() - const ack = vi.fn(() => pending.resolve()) - f.dispatcher.onNotification('pty.write', write) - f.dispatcher.onNotification('git.responseAck', ack) - f.send({ id: 1, method: 'git.diff' }) - const drain = f.dispatcher.beginWorkDrain() - f.send({ method: 'pty.write' }) - f.send({ method: 'rpc.cancel', params: { id: 1 } }) - expect(signal?.aborted).toBe(true) - f.send({ method: 'git.responseAck' }) - await drain - expect(write).not.toHaveBeenCalled() - expect(ack).toHaveBeenCalledOnce() -}) - -it('lets an admitted operation receive its reverse RPC response while draining', async () => { - const f = fixture() - f.dispatcher.onRequest('orca.cli', () => f.dispatcher.requestPrimary('client.operation')) - f.send({ id: 10, method: 'orca.cli' }) - const drain = f.dispatcher.beginWorkDrain() - const request = f.responses.find((frame) => frame.method === 'client.operation')! - expect(request).toBeDefined() - f.send({ id: Number(request.id), result: 'done' }) - await drain - await vi.waitFor(() => - expect(f.responses).toContainEqual({ jsonrpc: '2.0', id: 10, result: 'done' }) - ) -}) - -it('keeps skill upload cancellation executable while admitted installation work drains', async () => { - const f = fixture() - const work = Promise.withResolvers() - f.dispatcher.onRequest('skills.install', () => work.promise) - const cancel = vi.fn(async () => work.resolve()) - f.dispatcher.onRequest('skills.cancelUpload', cancel) - f.send({ id: 1, method: 'skills.install' }) - const drain = f.dispatcher.beginWorkDrain() - f.send({ id: 2, method: 'skills.cancelUpload' }) - await drain - expect(cancel).toHaveBeenCalledOnce() -}) - -it('keeps PTY source delivery cancellation executable while admitted work drains', async () => { - const f = fixture() - const work = Promise.withResolvers() - f.dispatcher.onRequest('pty.attach', () => work.promise) - const cancel = vi.fn(async () => ({ canceled: true, sentEndSu: 0, creditedEndSu: 0 })) - f.dispatcher.onRequest('pty.cancelDelivery', cancel) - f.send({ id: 1, method: 'pty.attach' }) - const drain = f.dispatcher.beginWorkDrain() - f.send({ id: 2, method: 'pty.cancelDelivery' }) - await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()) - work.resolve() - await drain - await vi.waitFor(() => - expect(f.responses).toContainEqual( - expect.objectContaining({ id: 2, result: expect.objectContaining({ canceled: true }) }) - ) - ) -}) diff --git a/src/relay/relay-producer-publication-drain.test.ts b/src/relay/relay-producer-publication-drain.test.ts deleted file mode 100644 index 0b1ddbe89a23..000000000000 --- a/src/relay/relay-producer-publication-drain.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' -import { RelayProducerPublicationDrain } from './relay-producer-publication-drain' - -describe('relay producer publication drain', () => { - const dispatchers: RelayDispatcher[] = [] - const setup = (synchronous = false) => { - const writes: ((result: SinkWriteSettlement) => void)[] = [] - const dispatcher = new RelayDispatcher( - (_bytes, settle) => { - writes.push(settle) - if (synchronous) { - settle({ ok: true }) - } - return true - }, - { supportsWriteCallback: true } - ) - dispatchers.push(dispatcher) - const assertAuthority = vi.fn() - const publication = new RelayProducerPublicationDrain(dispatcher, 1, 0, assertAuthority) - return { dispatcher, publication, writes, assertAuthority } - } - afterEach(() => { - for (const dispatcher of dispatchers.splice(0)) { - dispatcher.dispose() - } - }) - - it('waits for all callbacks, including frames published while waiting', async () => { - const { publication, writes } = setup() - publication.publish('tunnel.frame', {}) - const done = vi.fn() - const drain = publication.drain(new AbortController().signal).then(done) - publication.publish('tunnel.frame', {}) - writes[0]({ ok: true }) - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - expect(() => publication.assertDrained()).toThrow('not_drained') - writes[1]({ ok: true }) - await drain - expect(done).toHaveBeenCalledOnce() - }) - - it('accounts before synchronous settlement', async () => { - const { publication } = setup(true) - expect(publication.publish('tunnel.frame', {})).toBe(true) - await publication.drain(new AbortController().signal) - expect(() => publication.assertDrained()).not.toThrow() - }) - - it('aborts only observation and retains an ensuing failure for retry', async () => { - const { publication, writes } = setup() - publication.publish('tunnel.frame', {}) - const controller = new AbortController() - const drain = publication.drain(controller.signal) - controller.abort(new Error('observer cancelled')) - await expect(drain).rejects.toThrow('observer cancelled') - expect(() => publication.assertDrained()).toThrow('not_drained') - writes[0]({ ok: false, error: new Error('lost write') }) - await expect(publication.drain(new AbortController().signal)).rejects.toThrow('lost write') - expect(publication.publish('tunnel.frame', {})).toBe(false) - expect(writes).toHaveLength(1) - }) - - it('can retry an aborted observation after successful write settlement', async () => { - const { publication, writes } = setup() - publication.publish('tunnel.frame', {}) - const controller = new AbortController() - const drain = publication.drain(controller.signal) - controller.abort() - await expect(drain).rejects.toThrow() - writes[0]({ ok: true }) - await publication.drain(new AbortController().signal) - }) - - it('wakes every observer on failure despite outstanding writes', async () => { - const { publication } = setup() - publication.publish('tunnel.frame', {}) - const first = publication.drain(new AbortController().signal) - const second = publication.drain(new AbortController().signal) - publication.fail(new Error('transport lost')) - await expect(first).rejects.toThrow('transport lost') - await expect(second).rejects.toThrow('transport lost') - }) - - it('rejects replacement even when all old writes already completed', () => { - const { publication, dispatcher } = setup(true) - publication.publish('tunnel.frame', {}) - const replacement = vi.fn(() => true) - dispatcher.setWrite(replacement, { supportsWriteCallback: true }) - expect(() => publication.assertDrained()).toThrow('transport_unverifiable') - expect(publication.publish('tunnel.frame', {})).toBe(false) - expect(replacement).not.toHaveBeenCalled() - }) - - it('fails pending writes on replacement and ignores their late success', async () => { - const { publication, dispatcher, writes } = setup() - publication.publish('tunnel.frame', {}) - const drain = publication.drain(new AbortController().signal) - dispatcher.setWrite(() => true, { supportsWriteCallback: true }) - writes[0]({ ok: true }) - await expect(drain).rejects.toThrow('sink replaced') - }) - - it('refuses callback-less sinks, even if they synchronously accept writes', () => { - const dispatcher = new RelayDispatcher(() => true) - dispatchers.push(dispatcher) - expect(() => new RelayProducerPublicationDrain(dispatcher, 1, 0, () => {})).toThrow( - 'write_callback_required' - ) - }) - - it('retains pre-drain serialization failure', async () => { - const { publication, writes } = setup() - const params: Record = {} - params.circular = params - expect(publication.publish('tunnel.frame', params)).toBe(false) - await expect(publication.drain(new AbortController().signal)).rejects.toThrow() - expect(writes).toHaveLength(0) - }) - - it('revalidates generation after serialization before enqueue', () => { - const { publication, dispatcher, writes } = setup() - const replacement = vi.fn(() => true) - expect( - publication.publish('tunnel.frame', { - toJSON: () => { - dispatcher.setWrite(replacement, { supportsWriteCallback: true }) - return {} - } - }) - ).toBe(false) - expect(writes).toHaveLength(0) - expect(replacement).not.toHaveBeenCalled() - expect(() => publication.assertDrained()).toThrow('transport_unverifiable') - }) - - it('revalidates owner at emission and retains its failure', () => { - const { publication, assertAuthority, writes } = setup() - expect( - publication.publish('tunnel.frame', { - toJSON: () => { - assertAuthority.mockImplementation(() => { - throw new Error('owner changed') - }) - return {} - } - }) - ).toBe(false) - expect(writes).toHaveLength(0) - expect(() => publication.assertDrained()).toThrow('owner changed') - }) - - it('refuses queued frames when ownership changes during backpressure', async () => { - let resume: (() => void) | undefined - let ownerCurrent = true - const writes: ((result: SinkWriteSettlement) => void)[] = [] - const dispatcher = new RelayDispatcher( - (_bytes, settle) => { - writes.push(settle) - return false - }, - { - supportsWriteCallback: true, - waitWriteDrain: (callback) => { - resume = callback - } - } - ) - dispatchers.push(dispatcher) - const publication = new RelayProducerPublicationDrain(dispatcher, 1, 0, () => { - if (!ownerCurrent) { - throw new Error('owner replaced while queued') - } - }) - expect(publication.publish('tunnel.frame', { sequence: 1 })).toBe(true) - expect(publication.publish('tunnel.frame', { sequence: 2 })).toBe(true) - expect(writes).toHaveLength(1) - const drain = publication.drain(new AbortController().signal) - ownerCurrent = false - writes[0]({ ok: true }) - expect(resume).toBeTypeOf('function') - resume!() - await expect(drain).rejects.toThrow('owner replaced while queued') - expect(writes).toHaveLength(1) - }) -}) diff --git a/src/relay/relay-producer-publication-drain.ts b/src/relay/relay-producer-publication-drain.ts deleted file mode 100644 index 8ead8ac105d4..000000000000 --- a/src/relay/relay-producer-publication-drain.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { TransportPublicationDrain } from '../shared/transport-publication-drain' -import type { RelayDispatcher } from './dispatcher' - -type PublicationDispatcher = Pick< - RelayDispatcher, - 'publishProducerNotification' | 'assertSettledProducerTransport' -> - -/** Local write completion only; the tunnel separately proves downstream consumption. */ -export class RelayProducerPublicationDrain extends TransportPublicationDrain { - constructor( - private readonly dispatcher: PublicationDispatcher, - private readonly clientId: number, - private readonly transportGeneration: number, - assertAuthority: () => void, - onFailure: (error: Error) => void = () => {} - ) { - super(() => { - assertAuthority() - dispatcher.assertSettledProducerTransport(clientId, transportGeneration) - }, onFailure) - } - - publish(method: string, params: Record): boolean { - const settle = this.trackWrite() - try { - this.assertCurrent() - const accepted = this.dispatcher.publishProducerNotification(this.clientId, method, params, { - logDrop: false, - settledTransportGeneration: this.transportGeneration, - isStillAdmitted: () => { - try { - this.assertCurrent() - return true - } catch { - return false - } - }, - onSettled: settle - }) - if (!accepted) { - settle({ ok: false, error: new Error('relay_producer_publication_refused') }) - } - return accepted && !this.failure - } catch (error) { - settle({ ok: false, error: error instanceof Error ? error : new Error(String(error)) }) - return false - } - } -} diff --git a/src/relay/relay-work-admission.ts b/src/relay/relay-work-admission.ts index 2bf2ecdab4e5..4003f7556589 100644 --- a/src/relay/relay-work-admission.ts +++ b/src/relay/relay-work-admission.ts @@ -10,22 +10,15 @@ export class RelayWorkAdmission { return !this.draining || allowsRelayWorkDuringDrain(method, notification) } - // Why not async: an async wrapper adds microtask turns to every relay handler's response. - run(method: string, context: RequestContext, operation: () => T | Promise): Promise { + async run( + method: string, + context: RequestContext, + operation: () => T | Promise + ): Promise { if (!this.allows(method)) { - return Promise.reject(new Error('relay_work_admission_closed')) + throw new Error('relay_work_admission_closed') } - const finish = this.trackEntry(context) - let result: Promise - try { - const value = operation() - result = value instanceof Promise ? value : Promise.resolve(value) - } catch (error) { - finish() - return Promise.reject(error) - } - result.then(finish, finish) - return result + return this.track(context, operation) } runNotification(method: string, context: RequestContext, operation: () => void): void { @@ -66,6 +59,15 @@ export class RelayWorkAdmission { } } + private async track(context: RequestContext, operation: () => T | Promise): Promise { + const finish = this.trackEntry(context) + try { + return await operation() + } finally { + finish() + } + } + // Why: no Promise.withResolvers — the legacy relay bundle still targets Node 18 hosts. private trackEntry(context: RequestContext): () => void { const key = {} diff --git a/src/shared/relay-work-drain-contract.ts b/src/shared/relay-work-drain-contract.ts index 5982f7aec087..9b21e6bcfe51 100644 --- a/src/shared/relay-work-drain-contract.ts +++ b/src/shared/relay-work-drain-contract.ts @@ -6,9 +6,6 @@ const drainRequests = new Set([ 'relay.status', 'fs.unwatchAndWait', 'agent.cancelExec', - // Why (not in #16741): it only retires an existing delivery and replays its proof on retry; - // refusing it turns a provable client cancellation into an unverifiable one. - 'pty.cancelDelivery', SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD ]) const drainNotifications = new Set([ From cb60b07f81b529ffd36a565737d307e374043cc5 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 23:52:05 -0700 Subject: [PATCH 26/26] Revert "feat(relay): land the #16741 T1 seam (work drain, publication drain, release gate) (#24156)" This reverts commit 3aa2d3af7cbaa556b6c2e642d545f0ffdb53a0ef. --- src/relay/relay-work-admission.test.ts | 223 ------------------ src/relay/relay-work-admission.ts | 84 ------- ...ty-ownership-transfer-release-gate.test.ts | 29 --- .../pty-ownership-transfer-release-gate.ts | 12 - src/shared/relay-work-drain-contract.ts | 24 -- .../runtime-rpc-call-queue-retirement.test.ts | 65 ----- src/shared/runtime-rpc-call-queue.ts | 45 +--- .../transport-publication-drain.test.ts | 70 ------ src/shared/transport-publication-drain.ts | 87 ------- 9 files changed, 1 insertion(+), 638 deletions(-) delete mode 100644 src/relay/relay-work-admission.test.ts delete mode 100644 src/relay/relay-work-admission.ts delete mode 100644 src/shared/pty-ownership-transfer-release-gate.test.ts delete mode 100644 src/shared/pty-ownership-transfer-release-gate.ts delete mode 100644 src/shared/relay-work-drain-contract.ts delete mode 100644 src/shared/runtime-rpc-call-queue-retirement.test.ts delete mode 100644 src/shared/transport-publication-drain.test.ts delete mode 100644 src/shared/transport-publication-drain.ts diff --git a/src/relay/relay-work-admission.test.ts b/src/relay/relay-work-admission.test.ts deleted file mode 100644 index d5925953b116..000000000000 --- a/src/relay/relay-work-admission.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RequestContext } from './dispatcher-contract' -import { RelayWorkAdmission } from './relay-work-admission' -import { SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD } from '../shared/skill-ssh-relay-contract' - -function context(): RequestContext { - return { clientId: 1, isStale: () => false } -} - -function pendingOperation() { - let resolve!: () => void - const promise = new Promise((settle) => { - resolve = settle - }) - return { promise, resolve } -} - -async function nextTurn(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('RelayWorkAdmission', () => { - it('starts admitted operations synchronously and preserves their result', async () => { - const admission = new RelayWorkAdmission() - const operation = vi.fn(() => 'started') - const result = admission.run('fs.writeFile', context(), operation) - expect(operation).toHaveBeenCalledOnce() - await expect(result).resolves.toBe('started') - }) - - it('closes admission synchronously and drains existing work despite abort and disconnect', async () => { - const admission = new RelayWorkAdmission() - const controller = new AbortController() - let disconnected = false - const mutation = pendingOperation() - const running = admission.run( - 'fs.writeFile', - { ...context(), signal: controller.signal, isStale: () => disconnected }, - () => mutation.promise - ) - const drained = vi.fn() - const drain = admission.beginDrain().then(drained) - const rejectedOperation = vi.fn() - await expect(admission.run('fs.writeFile', context(), rejectedOperation)).rejects.toThrow( - 'relay_work_admission_closed' - ) - expect(rejectedOperation).not.toHaveBeenCalled() - controller.abort() - disconnected = true - await nextTurn() - expect(drained).not.toHaveBeenCalled() - mutation.resolve() - await running - await drain - expect(drained).toHaveBeenCalledOnce() - }) - - it.each([ - 'relay.status', - 'fs.unwatchAndWait', - 'agent.cancelExec', - SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD - ])('admits cleanup request %s while draining', async (method) => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const running = admission.run('git.diff', context(), () => mutation.promise) - const drain = admission.beginDrain() - await expect( - admission.run(method, context(), () => { - mutation.resolve() - return 'acknowledged' - }) - ).resolves.toBe('acknowledged') - await running - await drain - }) - - it.each([ - 'rpc.cancel', - 'git.responseAck', - 'git.cancelResponseStream', - 'fs.streamAck', - 'fs.cancelStream', - 'fs.unwatch', - 'pty.ackData', - 'pty.setDeliveryPaused' - ])('admits cleanup notification %s while draining', async (method) => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const running = admission.run('git.diff', context(), () => mutation.promise) - const drain = admission.beginDrain() - const acknowledge = vi.fn(() => mutation.resolve()) - admission.runNotification(method, context(), acknowledge) - expect(acknowledge).toHaveBeenCalledOnce() - await running - await drain - await expect(admission.run('git.diff', context(), () => {})).rejects.toThrow( - 'relay_work_admission_closed' - ) - }) - - it('excludes the exact reset request without excluding another request from the same client', async () => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const running = admission.run('fs.writeFile', context(), () => mutation.promise) - const resetContext = context() - const drained = vi.fn() - const reset = admission.run('relay.reset', resetContext, async () => { - await admission.beginDrain(resetContext) - drained() - }) - await nextTurn() - expect(drained).not.toHaveBeenCalled() - mutation.resolve() - await running - await reset - expect(drained).toHaveBeenCalledOnce() - }) - - it('rejects a forged context without closing admission', async () => { - const admission = new RelayWorkAdmission() - const actualContext = context() - await admission.run('relay.reset', actualContext, async () => { - await expect(admission.beginDrain({ ...actualContext })).rejects.toThrow( - 'relay_work_drain_context_not_active' - ) - await expect(admission.run('fs.writeFile', context(), () => 'open')).resolves.toBe('open') - }) - }) - - it('rejects an already-settled request context without closing admission', async () => { - const admission = new RelayWorkAdmission() - const oldContext = context() - await admission.run('relay.reset', oldContext, () => {}) - await expect(admission.beginDrain(oldContext)).rejects.toThrow( - 'relay_work_drain_context_not_active' - ) - await expect(admission.run('fs.writeFile', context(), () => 'open')).resolves.toBe('open') - }) - - it('waits for reset work when the host lifecycle supplies no exclusion', async () => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const reset = admission.run('relay.reset', context(), () => mutation.promise) - const drained = vi.fn() - const drain = admission.beginDrain().then(drained) - await nextTurn() - expect(drained).not.toHaveBeenCalled() - mutation.resolve() - await reset - await drain - expect(drained).toHaveBeenCalledOnce() - }) - - it('drops new mutation notifications without invoking their handlers during drain', async () => { - const admission = new RelayWorkAdmission() - await admission.beginDrain() - const mutate = vi.fn() - admission.runNotification('pty.write', context(), mutate) - expect(mutate).not.toHaveBeenCalled() - await expect(admission.run('pty.ackData', context(), mutate)).rejects.toThrow( - 'relay_work_admission_closed' - ) - }) - - it('tracks asynchronous notifications through settlement while draining', async () => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const operation = vi.fn(async () => mutation.promise) - admission.runNotification('pty.write', context(), operation) - expect(operation).toHaveBeenCalledOnce() - const drained = vi.fn() - const drain = admission.beginDrain().then(drained) - await nextTurn() - expect(drained).not.toHaveBeenCalled() - mutation.resolve() - await drain - expect(drained).toHaveBeenCalledOnce() - }) - - it('releases tracking after synchronous notification failure', async () => { - const admission = new RelayWorkAdmission() - expect(() => - admission.runNotification('pty.write', context(), () => { - throw new Error('notification_failed') - }) - ).toThrow('notification_failed') - await admission.beginDrain() - const mutate = vi.fn() - admission.runNotification('pty.write', context(), mutate) - expect(mutate).not.toHaveBeenCalled() - }) - - it('settles concurrent drains after an admitted operation fails without reopening', async () => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const running = admission.run('fs.writeFile', context(), async () => { - await mutation.promise - throw new Error('write_failed') - }) - const failure = expect(running).rejects.toThrow('write_failed') - const firstDrain = admission.beginDrain() - const secondDrain = admission.beginDrain() - mutation.resolve() - await failure - await Promise.all([firstDrain, secondDrain]) - await expect(admission.run('fs.writeFile', context(), () => {})).rejects.toThrow( - 'relay_work_admission_closed' - ) - }) - - it('turns synchronous operation failures into rejected promises and releases tracking', async () => { - const admission = new RelayWorkAdmission() - const result = admission.run('fs.writeFile', context(), () => { - throw new Error('write_failed') - }) - await expect(result).rejects.toThrow('write_failed') - await admission.beginDrain() - await expect(admission.run('fs.writeFile', context(), () => {})).rejects.toThrow( - 'relay_work_admission_closed' - ) - }) -}) diff --git a/src/relay/relay-work-admission.ts b/src/relay/relay-work-admission.ts deleted file mode 100644 index 4003f7556589..000000000000 --- a/src/relay/relay-work-admission.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { RequestContext } from './dispatcher-contract' -import { allowsRelayWorkDuringDrain } from '../shared/relay-work-drain-contract' - -/** Handler drain is not proof of physical process exit or detached producer cleanup. */ -export class RelayWorkAdmission { - private draining = false - private readonly active = new Map }>() - - allows(method: string, notification = false): boolean { - return !this.draining || allowsRelayWorkDuringDrain(method, notification) - } - - async run( - method: string, - context: RequestContext, - operation: () => T | Promise - ): Promise { - if (!this.allows(method)) { - throw new Error('relay_work_admission_closed') - } - return this.track(context, operation) - } - - runNotification(method: string, context: RequestContext, operation: () => void): void { - if (!this.allows(method, true)) { - return - } - const finish = this.trackEntry(context) - try { - const result = operation() - void Promise.resolve(result).then(finish, (error) => { - finish() - process.stderr.write(`[relay] Notification handler failed: ${String(error)}\n`) - }) - } catch (error) { - finish() - throw error - } - } - - assertActiveContext(context: RequestContext): void { - if (![...this.active.values()].some((entry) => entry.context === context)) { - throw new Error('relay_work_drain_context_not_active') - } - } - - /** Only the actual initiating request context may be excluded from its own drain. */ - async beginDrain(exclude?: RequestContext): Promise { - if (exclude) { - this.assertActiveContext(exclude) - } - this.draining = true - for (;;) { - const pending = [...this.active.values()].filter((entry) => entry.context !== exclude) - if (pending.length === 0) { - return - } - await Promise.all(pending.map((entry) => entry.done)) - } - } - - private async track(context: RequestContext, operation: () => T | Promise): Promise { - const finish = this.trackEntry(context) - try { - return await operation() - } finally { - finish() - } - } - - // Why: no Promise.withResolvers — the legacy relay bundle still targets Node 18 hosts. - private trackEntry(context: RequestContext): () => void { - const key = {} - let resolve!: () => void - const done = new Promise((settle) => { - resolve = settle - }) - this.active.set(key, { context, done }) - return () => { - this.active.delete(key) - resolve() - } - } -} diff --git a/src/shared/pty-ownership-transfer-release-gate.test.ts b/src/shared/pty-ownership-transfer-release-gate.test.ts deleted file mode 100644 index dbfc79ed6ae9..000000000000 --- a/src/shared/pty-ownership-transfer-release-gate.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - isPtyOwnershipTransferMutationEnabled, - PTY_OWNERSHIP_TRANSFER_CANARY_ENV -} from './pty-ownership-transfer-release-gate' - -describe('PTY ownership-transfer release gate', () => { - it('is closed unless the exact canary value is present', () => { - expect(isPtyOwnershipTransferMutationEnabled({})).toBe(false) - expect( - isPtyOwnershipTransferMutationEnabled({ - [PTY_OWNERSHIP_TRANSFER_CANARY_ENV]: 'true' - }) - ).toBe(false) - expect( - isPtyOwnershipTransferMutationEnabled({ - [PTY_OWNERSHIP_TRANSFER_CANARY_ENV]: '1 ' - }) - ).toBe(false) - }) - - it('opens only for an explicit canary opt-in', () => { - expect( - isPtyOwnershipTransferMutationEnabled({ - [PTY_OWNERSHIP_TRANSFER_CANARY_ENV]: '1' - }) - ).toBe(true) - }) -}) diff --git a/src/shared/pty-ownership-transfer-release-gate.ts b/src/shared/pty-ownership-transfer-release-gate.ts deleted file mode 100644 index 2c411d897077..000000000000 --- a/src/shared/pty-ownership-transfer-release-gate.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * The live PTY ownership-transfer path is a release canary until routed, - * cross-platform recovery evidence is complete. Keep the opt-in exact and - * process-scoped so ordinary profiles cannot inherit it accidentally. - */ -export const PTY_OWNERSHIP_TRANSFER_CANARY_ENV = 'ORCA_ENABLE_PTY_OWNERSHIP_TRANSFER_MUTATION' - -export function isPtyOwnershipTransferMutationEnabled( - env: Record = process.env -): boolean { - return env[PTY_OWNERSHIP_TRANSFER_CANARY_ENV] === '1' -} diff --git a/src/shared/relay-work-drain-contract.ts b/src/shared/relay-work-drain-contract.ts deleted file mode 100644 index 9b21e6bcfe51..000000000000 --- a/src/shared/relay-work-drain-contract.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD } from './skill-ssh-relay-contract' - -// Why: a drain must still admit the requests and notifications that let in-flight work finish or cancel. -// Owner-reset (T3) and network-tunnel (T4) methods join these sets when their contracts land. -const drainRequests = new Set([ - 'relay.status', - 'fs.unwatchAndWait', - 'agent.cancelExec', - SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD -]) -const drainNotifications = new Set([ - 'rpc.cancel', - 'git.responseAck', - 'git.cancelResponseStream', - 'fs.streamAck', - 'fs.cancelStream', - 'fs.unwatch', - 'pty.ackData', - 'pty.setDeliveryPaused' -]) - -export function allowsRelayWorkDuringDrain(method: string, notification = false): boolean { - return (notification ? drainNotifications : drainRequests).has(method) -} diff --git a/src/shared/runtime-rpc-call-queue-retirement.test.ts b/src/shared/runtime-rpc-call-queue-retirement.test.ts deleted file mode 100644 index 3958c2009919..000000000000 --- a/src/shared/runtime-rpc-call-queue-retirement.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { RuntimeRpcCallQueueBusyError, RuntimeRpcCallQueuePool } from './runtime-rpc-call-queue' - -it('refuses an active or queued selector without interrupting or replaying its calls', async () => { - const queue = new RuntimeRpcCallQueuePool(1, 1) - const pending = Promise.withResolvers() - const first = queue.enqueue('host', 'terminal.send', () => pending.promise) - const secondRun = vi.fn(async () => 'second-ack') - const second = queue.enqueue('host', 'terminal.send', secondRun) - expect(() => queue.holdIdleSelectors(['host'])).toThrow(RuntimeRpcCallQueueBusyError) - pending.resolve('first-ack') - await expect(first).resolves.toBe('first-ack') - await expect(second).resolves.toBe('second-ack') - expect(secondRun).toHaveBeenCalledOnce() - await vi.waitFor(() => queue.holdIdleSelectors(['host'])()) -}) - -it('holds both identities, refuses new calls before dispatch, and leaves other hosts running', async () => { - const queue = new RuntimeRpcCallQueuePool() - const release = queue.holdIdleSelectors(['canonical', 'historical', 'canonical']) - const run = vi.fn(async () => 'ack') - for (const id of ['canonical', 'historical']) { - await expect(queue.enqueue(id, 'terminal.send', run)).rejects.toMatchObject({ - code: 'runtime_rpc_queue_busy' - }) - } - expect(run).not.toHaveBeenCalled() - await expect(queue.enqueue('other', 'terminal.send', run)).resolves.toBe('ack') - release() - await expect(queue.enqueue('historical', 'terminal.send', run)).resolves.toBe('ack') - expect(run).toHaveBeenCalledTimes(2) -}) - -it('acquires a group atomically without retaining a partial hold on failure', async () => { - const queue = new RuntimeRpcCallQueuePool() - const release = queue.holdIdleSelectors(['historical']) - expect(() => queue.holdIdleSelectors(['canonical', 'historical'])).toThrow( - RuntimeRpcCallQueueBusyError - ) - await expect(queue.enqueue('canonical', 'repo.list', async () => 'ok')).resolves.toBe('ok') - release() -}) - -it('does not let an old release clear a newer hold', async () => { - const queue = new RuntimeRpcCallQueuePool() - const first = queue.holdIdleSelectors(['host']) - first() - const second = queue.holdIdleSelectors(['host']) - first() - await expect(queue.enqueue('host', 'repo.list', async () => 'ok')).rejects.toThrow( - RuntimeRpcCallQueueBusyError - ) - second() - await expect(queue.enqueue('host', 'repo.list', async () => 'ok')).resolves.toBe('ok') -}) - -it('counts a selector busy while only its long-wait lane has a call', async () => { - const queue = new RuntimeRpcCallQueuePool() - const pending = Promise.withResolvers() - const removal = queue.enqueue('host', 'worktree.rm', () => pending.promise) - expect(() => queue.holdIdleSelectors(['host'])).toThrow(RuntimeRpcCallQueueBusyError) - pending.resolve('removed') - await expect(removal).resolves.toBe('removed') - await vi.waitFor(() => queue.holdIdleSelectors(['host'])()) -}) diff --git a/src/shared/runtime-rpc-call-queue.ts b/src/shared/runtime-rpc-call-queue.ts index 0503c5e06c0b..132c80992734 100644 --- a/src/shared/runtime-rpc-call-queue.ts +++ b/src/shared/runtime-rpc-call-queue.ts @@ -16,15 +16,6 @@ export class RuntimeRpcCallQueueOverloadError extends Error { } } -export class RuntimeRpcCallQueueBusyError extends Error { - readonly code = 'runtime_rpc_queue_busy' - - constructor() { - super('Runtime calls are active or their routing is changing; retry after they settle.') - this.name = 'RuntimeRpcCallQueueBusyError' - } -} - type QueuedRuntimeCall = { background: boolean retainedBytes: number @@ -66,13 +57,8 @@ function isLongWaitRuntimeMethod(method: string): boolean { return method === 'worktree.rm' } -function longWaitQueueKey(selector: string): string { - return `${selector}\u0000long-wait` -} - export class RuntimeRpcCallQueuePool { private readonly queues = new Map() - private readonly heldSelectors = new Set() private queuedCallCount = 0 private retainedCallBytes = 0 @@ -84,32 +70,6 @@ export class RuntimeRpcCallQueuePool { private readonly maxRetainedBytes = REMOTE_RUNTIME_MAX_PREPARED_RPC_BYTES ) {} - /** Acquires all idle selectors atomically; release never replays refused calls. */ - holdIdleSelectors(selectors: readonly string[]): () => void { - const unique = [...new Set(selectors)] - // A selector's long-wait lane counts too: holding it must see every call still in flight. - const busy = (selector: string): boolean => - this.heldSelectors.has(selector) || - this.queues.has(selector) || - this.queues.has(longWaitQueueKey(selector)) - if (unique.some(busy)) { - throw new RuntimeRpcCallQueueBusyError() - } - for (const selector of unique) { - this.heldSelectors.add(selector) - } - let released = false - return () => { - if (released) { - return - } - released = true - for (const selector of unique) { - this.heldSelectors.delete(selector) - } - } - } - enqueue( selector: string, method: string, @@ -120,11 +80,8 @@ export class RuntimeRpcCallQueuePool { if (signal?.aborted) { return Promise.reject(abortSignalReason(signal)) } - if (this.heldSelectors.has(selector)) { - return Promise.reject(new RuntimeRpcCallQueueBusyError()) - } // Same concurrency bound, counted apart from the selector's other calls; global caps still apply. - const queueKey = isLongWaitRuntimeMethod(method) ? longWaitQueueKey(selector) : selector + const queueKey = isLongWaitRuntimeMethod(method) ? `${selector}\u0000long-wait` : selector if (this.queuedCallCount >= this.maxQueuedTotal) { return Promise.reject(new RuntimeRpcCallQueueOverloadError('global')) } diff --git a/src/shared/transport-publication-drain.test.ts b/src/shared/transport-publication-drain.test.ts deleted file mode 100644 index e515cf464f68..000000000000 --- a/src/shared/transport-publication-drain.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { TransportPublicationDrain } from './transport-publication-drain' - -describe('TransportPublicationDrain', () => { - it('waits for every tracked write, including writes tracked while draining', async () => { - const publication = new TransportPublicationDrain(() => {}) - const first = publication.trackWrite() - const done = vi.fn() - const drain = publication.drain(new AbortController().signal).then(done) - const second = publication.trackWrite() - first({ ok: true }) - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - expect(() => publication.assertDrained()).toThrow('transport_publication_not_drained') - second({ ok: true }) - await drain - expect(done).toHaveBeenCalledOnce() - }) - - it('ignores a repeated settlement of the same write', async () => { - const publication = new TransportPublicationDrain(() => {}) - const settle = publication.trackWrite() - const other = publication.trackWrite() - settle({ ok: true }) - settle({ ok: true }) - expect(() => publication.assertDrained()).toThrow('transport_publication_not_drained') - other({ ok: true }) - await publication.drain(new AbortController().signal) - }) - - it('aborts only the observer and keeps a later write failure sticky', async () => { - const onFailure = vi.fn() - const publication = new TransportPublicationDrain(() => {}, onFailure) - const settle = publication.trackWrite() - const controller = new AbortController() - const drain = publication.drain(controller.signal) - controller.abort(new Error('observer cancelled')) - await expect(drain).rejects.toThrow('observer cancelled') - settle({ ok: false, error: new Error('lost write') }) - expect(onFailure).toHaveBeenCalledOnce() - await expect(publication.drain(new AbortController().signal)).rejects.toThrow('lost write') - expect(() => publication.assertCurrent()).toThrow('lost write') - }) - - it('fails once when the transport it was bound to is replaced', async () => { - let current = true - const onFailure = vi.fn() - const publication = new TransportPublicationDrain(() => { - if (!current) { - throw new Error('transport_replaced') - } - }, onFailure) - const settle = publication.trackWrite() - current = false - const drain = publication.drain(new AbortController().signal) - await expect(drain).rejects.toThrow('transport_replaced') - settle({ ok: true }) - expect(() => publication.assertDrained()).toThrow('transport_replaced') - expect(onFailure).toHaveBeenCalledOnce() - }) - - it('refuses construction against a transport that is already stale', () => { - expect( - () => - new TransportPublicationDrain(() => { - throw new Error('transport_replaced') - }) - ).toThrow('transport_replaced') - }) -}) diff --git a/src/shared/transport-publication-drain.ts b/src/shared/transport-publication-drain.ts deleted file mode 100644 index 3814b1201957..000000000000 --- a/src/shared/transport-publication-drain.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { waitForPromiseWithSignal } from './abort-signal-reason' - -export type TransportPublicationSettlement = { ok: true } | { ok: false; error: Error } - -/** Local write completion only; downstream consumption requires separate proof. */ -export class TransportPublicationDrain { - private pending = 0 - protected failure: Error | undefined - private readonly observers = new Set<() => void>() - - constructor( - private readonly assertTransport: () => void, - private readonly onFailure: (error: Error) => void = () => {} - ) { - this.assertCurrent() - } - - trackWrite(): (result: TransportPublicationSettlement) => void { - this.pending++ - let settled = false - return (result) => { - if (settled) { - return - } - settled = true - this.pending-- - if (!result.ok) { - this.fail(result.error) - } - this.changed() - } - } - - fail(error: Error): void { - if (this.failure) { - return - } - this.failure = error - this.changed() - this.onFailure(error) - } - - assertDrained(): void { - this.assertCurrent() - if (this.pending !== 0) { - throw new Error('transport_publication_not_drained') - } - } - - async drain(signal: AbortSignal): Promise { - signal.throwIfAborted() - while (this.pending !== 0) { - this.assertCurrent() - // Why: no Promise.withResolvers — the legacy relay bundle still targets Node 18 hosts. - let notify!: () => void - const changed = new Promise((resolve) => { - notify = resolve - }) - this.observers.add(notify) - try { - await waitForPromiseWithSignal(changed, signal) - } finally { - this.observers.delete(notify) - } - } - signal.throwIfAborted() - this.assertDrained() - } - - assertCurrent(): void { - if (this.failure) { - throw this.failure - } - try { - this.assertTransport() - } catch (error) { - this.fail(error instanceof Error ? error : new Error(String(error))) - throw this.failure - } - } - - private changed(): void { - for (const notify of this.observers) { - notify() - } - } -}