diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d700112f486..bd518c980f3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -358,7 +358,8 @@ jobs: . != "tests/e2e/ssh-orcad-markdown-conversion.spec.ts" and . != "tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts" and . != "tests/e2e/ssh-orcad-markdown-live-documents.spec.ts" and - . != "tests/e2e/ssh-orcad-open-in-owner.spec.ts" + . != "tests/e2e/ssh-orcad-open-in-owner.spec.ts" and + . != "tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts" )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" fi mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs" @@ -592,7 +593,7 @@ jobs: orcad-auto-convert-docker: name: ssh host auto-converts to managed orcad (Docker) needs: [build, prepare-native-cache] - if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-routing.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-ownership.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-open-in-owner.spec.ts') + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-routing.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-ownership.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-open-in-owner.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts') runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -628,7 +629,7 @@ jobs: ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay run: | export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template" - xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts tests/e2e/ssh-orcad-browser-routing.spec.ts tests/e2e/ssh-orcad-editor-ownership.spec.ts tests/e2e/ssh-orcad-markdown-conversion.spec.ts tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts tests/e2e/ssh-orcad-markdown-live-documents.spec.ts tests/e2e/ssh-orcad-open-in-owner.spec.ts --project=electron-headless --workers=1 + xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts tests/e2e/ssh-orcad-browser-routing.spec.ts tests/e2e/ssh-orcad-editor-ownership.spec.ts tests/e2e/ssh-orcad-markdown-conversion.spec.ts tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts tests/e2e/ssh-orcad-markdown-live-documents.spec.ts tests/e2e/ssh-orcad-open-in-owner.spec.ts tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts --project=electron-headless --workers=1 - uses: actions/upload-artifact@v7 if: failure() with: diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index 920301e7087..637dc2a516f 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -63,6 +63,8 @@ export const ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC = export const ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC = 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts' export const ORCAD_OPEN_IN_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-open-in-owner.spec.ts' +export const ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC = + 'tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts' export const DEDICATED_E2E_SPECS = [ ...DOCKER_SSH_E2E_SPECS, NODE_NETWORK_E2E_SPEC, @@ -80,7 +82,8 @@ export const DEDICATED_E2E_SPECS = [ ORCAD_MARKDOWN_CONVERSION_E2E_SPEC, ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC, ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC, - ORCAD_OPEN_IN_OWNER_E2E_SPEC + ORCAD_OPEN_IN_OWNER_E2E_SPEC, + ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS) @@ -130,7 +133,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') { spec === ORCAD_MARKDOWN_CONVERSION_E2E_SPEC || spec === ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC || spec === ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC || - spec === ORCAD_OPEN_IN_OWNER_E2E_SPEC + spec === ORCAD_OPEN_IN_OWNER_E2E_SPEC || + spec === ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC ) } } diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 7c26e7af63d..98f7ee1a193 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -238,3 +238,17 @@ it.each([ spec ) }) + +it.each([ + 'src/renderer/src/hooks/useEditorExternalWatch.ts', + 'src/renderer/src/hooks/editor-runtime-file-watch.ts' +])('routes %s to the template-building editor watch recovery lane', (file) => { + const spec = 'tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts' + expect(selectPrE2eSpecs([file])).toContain(spec) + expect(classify([spec])).toEqual({ e2e_run_changed: false, e2e_needs_build: true }) + const job = workflow.jobs['orcad-auto-convert-docker'] + expect(job.if).toContain(spec) + expect(job.steps.find((step) => step.name === 'Convert a relay-era Docker host').run).toContain( + spec + ) +}) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 3461792de65..3a0c9fd4abd 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -50,6 +50,15 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) }, + { + id: 'ssh.orcad-editor-watch-recovery', + specs: ['tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts'], + matches: (file) => + isProductSource(file) && + /^src\/renderer\/src\/hooks\/(?:useEditorExternalWatch|editor-runtime-file-watch)\.ts$/.test( + file + ) + }, { id: 'serve.orcad-mode-switch', specs: ['tests/e2e/orcad-serve-mode-switch.spec.ts'], diff --git a/src/renderer/src/hooks/editor-runtime-file-watch.test.ts b/src/renderer/src/hooks/editor-runtime-file-watch.test.ts new file mode 100644 index 00000000000..13ed3a5a6b2 --- /dev/null +++ b/src/renderer/src/hooks/editor-runtime-file-watch.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FsChangedPayload } from '../../../shared/filesystem-entry-types' +import { subscribeEditorRuntimeFileWatch } from './editor-runtime-file-watch' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' + +const runtime = vi.hoisted(() => ({ + subscribe: vi.fn(), + contact: new Map void>(), + retire: new Set<(ids: readonly string[]) => void>(), + stopContact: vi.fn(), + stopRetirement: vi.fn(), + payloads: new Array<(payload: FsChangedPayload) => void>(), + errors: new Array<(error: Error) => void>() +})) +vi.mock('@/runtime/runtime-file-client', () => ({ + subscribeRuntimeFileChanges: runtime.subscribe +})) +vi.mock('@/runtime/runtime-host-contact-regained', () => ({ + subscribeRuntimeHostContactRegained: (id: string, listener: () => void) => { + runtime.contact.set(id, listener) + return runtime.stopContact + } +})) +vi.mock('@/runtime/runtime-environment-revision', () => ({ + onRuntimeEnvironmentsRetired: (listener: (ids: readonly string[]) => void) => { + runtime.retire.add(listener) + return runtime.stopRetirement + } +})) + +const target = { + worktreeId: 'wt', + worktreePath: '/repo', + connectionId: undefined, + runtimeEnvironmentId: 'host-a' +} +const change: FsChangedPayload = { + worktreePath: '/repo', + events: [{ kind: 'update', absolutePath: '/repo/README.md' }] +} +function pendingSubscription(): { + promise: Promise<() => void> + resolve: (stop: () => void) => void +} { + let resolve: (stop: () => void) => void = () => {} + const promise = new Promise<() => void>((done) => { + resolve = done + }) + return { promise, resolve } +} +async function settle(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +beforeEach(() => { + vi.clearAllMocks() + runtime.contact.clear() + runtime.retire.clear() + runtime.payloads.length = 0 + runtime.errors.length = 0 + runtime.subscribe.mockReset() +}) + +describe('editor runtime file watch recovery', () => { + it('routes the initial watch to its owner and leaves ordinary payloads unchanged', async () => { + const onPayload = vi.fn() + const stopStream = vi.fn() + runtime.subscribe.mockResolvedValue(stopStream) + const stop = subscribeEditorRuntimeFileWatch(target, onPayload, vi.fn()) + await settle() + expect(runtime.subscribe).toHaveBeenCalledWith( + { + settings: { activeRuntimeEnvironmentId: 'host-a' }, + worktreeId: 'wt', + worktreePath: '/repo', + connectionId: undefined + }, + expect.any(Function), + expect.any(Function) + ) + expect(onPayload).not.toHaveBeenCalled() + runtime.subscribe.mock.calls[0][1](change) + expect(onPayload).toHaveBeenCalledWith(change) + stop() + stop() + expect(stopStream).toHaveBeenCalledOnce() + expect(runtime.stopContact).toHaveBeenCalledOnce() + expect(runtime.stopRetirement).toHaveBeenCalledOnce() + }) + + it('retains a non-Git folder workspace and its host during recovery', async () => { + runtime.subscribe.mockResolvedValue(vi.fn()) + const folder = { ...target, worktreeId: folderWorkspaceKey('notes'), worktreePath: '/notes' } + const onPayload = vi.fn() + const stop = subscribeEditorRuntimeFileWatch(folder, onPayload, vi.fn()) + await settle() + runtime.contact.get('host-a')?.() + await settle() + expect(runtime.subscribe).toHaveBeenLastCalledWith( + { + settings: { activeRuntimeEnvironmentId: 'host-a' }, + worktreeId: folder.worktreeId, + worktreePath: '/notes', + connectionId: undefined + }, + expect.any(Function), + expect.any(Function) + ) + expect(onPayload).toHaveBeenCalledWith({ + worktreePath: '/notes', + events: [{ kind: 'overflow', absolutePath: '/notes' }] + }) + stop() + }) + + it('disposes late old stream handles without replacing the current handle', async () => { + const old = pendingSubscription() + const fresh = pendingSubscription() + const stopOld = vi.fn() + const stopFresh = vi.fn() + const onPayload = vi.fn() + runtime.subscribe.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise) + const stop = subscribeEditorRuntimeFileWatch(target, onPayload, vi.fn()) + runtime.contact.get('host-a')?.() + fresh.resolve(stopFresh) + await settle() + old.resolve(stopOld) + await settle() + expect(stopOld).toHaveBeenCalledOnce() + expect(stopFresh).not.toHaveBeenCalled() + expect(onPayload).toHaveBeenCalledOnce() + expect(onPayload).toHaveBeenCalledWith({ + worktreePath: '/repo', + events: [{ kind: 'overflow', absolutePath: '/repo' }] + }) + stop() + expect(stopFresh).toHaveBeenCalledOnce() + }) + + it('drops payloads and errors from replaced attempts and unrelated host contact', async () => { + const onPayload = vi.fn() + const onError = vi.fn() + runtime.subscribe.mockImplementation((_context, payload, error) => { + runtime.payloads.push(payload) + runtime.errors.push(error) + return Promise.resolve(vi.fn()) + }) + const stop = subscribeEditorRuntimeFileWatch(target, onPayload, onError) + await settle() + runtime.contact.get('host-b')?.() + expect(runtime.subscribe).toHaveBeenCalledOnce() + runtime.contact.get('host-a')?.() + await settle() + onPayload.mockClear() + runtime.payloads[0](change) + runtime.errors[0](new Error('old transport')) + expect(onPayload).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + runtime.payloads[1](change) + expect(onPayload).toHaveBeenCalledOnce() + stop() + runtime.payloads[1](change) + runtime.contact.get('host-a')?.() + expect(onPayload).toHaveBeenCalledOnce() + expect(runtime.subscribe).toHaveBeenCalledTimes(2) + }) + + it('stops on owner retirement so a same-ID replacement cannot revive old work', async () => { + const stopStream = vi.fn() + runtime.subscribe.mockResolvedValue(stopStream) + const stop = subscribeEditorRuntimeFileWatch(target, vi.fn(), vi.fn()) + await settle() + for (const retire of runtime.retire) { + retire(['host-b']) + } + expect(stopStream).not.toHaveBeenCalled() + for (const retire of runtime.retire) { + retire(['host-a']) + } + expect(stopStream).toHaveBeenCalledOnce() + runtime.contact.get('host-a')?.() + expect(runtime.subscribe).toHaveBeenCalledOnce() + stop() + expect(stopStream).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/hooks/editor-runtime-file-watch.ts b/src/renderer/src/hooks/editor-runtime-file-watch.ts new file mode 100644 index 00000000000..d88e425c369 --- /dev/null +++ b/src/renderer/src/hooks/editor-runtime-file-watch.ts @@ -0,0 +1,83 @@ +import type { FsChangedPayload } from '../../../shared/filesystem-entry-types' +import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client' +import { subscribeRuntimeHostContactRegained } from '@/runtime/runtime-host-contact-regained' +import { onRuntimeEnvironmentsRetired } from '@/runtime/runtime-environment-revision' +import type { EditorExternalWatchTarget } from './editor-external-watch-targets' + +export function subscribeEditorRuntimeFileWatch( + target: EditorExternalWatchTarget & { runtimeEnvironmentId: string }, + onPayload: (payload: FsChangedPayload) => void, + onError: (error: unknown) => void +): () => void { + let disposed = false + let attempt = 0 + let unsubscribe: (() => void) | null = null + + const start = (resync: boolean): void => { + if (disposed) { + return + } + const currentAttempt = ++attempt + unsubscribe?.() + unsubscribe = null + const isCurrent = (): boolean => !disposed && attempt === currentAttempt + void subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: target.runtimeEnvironmentId }, + worktreeId: target.worktreeId, + worktreePath: target.worktreePath, + connectionId: target.connectionId + }, + (payload) => { + if (isCurrent()) { + onPayload(payload) + } + }, + (error) => { + if (isCurrent()) { + onError(error) + } + } + ) + .then((stop) => { + if (!isCurrent()) { + stop() + return + } + unsubscribe = stop + // A restored stream cannot replay file changes missed while contact was lost. + if (resync) { + onPayload({ + worktreePath: target.worktreePath, + events: [{ kind: 'overflow', absolutePath: target.worktreePath }] + }) + } + }) + .catch((error: unknown) => { + if (isCurrent()) { + onError(error) + } + }) + } + const stopContact = subscribeRuntimeHostContactRegained(target.runtimeEnvironmentId, () => + start(true) + ) + const stopRetirement = onRuntimeEnvironmentsRetired((ids) => { + if (ids.includes(target.runtimeEnvironmentId)) { + dispose() + } + }) + function dispose(): void { + if (disposed) { + return + } + disposed = true + attempt += 1 + unsubscribe?.() + unsubscribe = null + stopContact() + stopRetirement() + } + start(false) + return dispose +} diff --git a/src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx b/src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx index ad0ee0a09d9..d1a3134adfe 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx +++ b/src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx @@ -15,12 +15,21 @@ type TestWatchTarget = { const subscriptionState = vi.hoisted(() => ({ snapshot: { targets: [] as TestWatchTarget[], targetsKey: '' }, subscribeRuntimeFileChanges: vi.fn(), - disposeEventHandler: vi.fn() + disposeEventHandler: vi.fn(), + handleFsChanged: vi.fn(), + contacts: new Map void>(), + unsubscribeContact: vi.fn() })) vi.mock('@/store', () => ({ useAppStore: (selector: (state: unknown) => unknown) => selector({}) })) +vi.mock('@/runtime/runtime-host-contact-regained', () => ({ + subscribeRuntimeHostContactRegained: (id: string, listener: () => void) => { + subscriptionState.contacts.set(id, listener) + return subscriptionState.unsubscribeContact + } +})) vi.mock('@/runtime/runtime-file-client', () => ({ subscribeRuntimeFileChanges: subscriptionState.subscribeRuntimeFileChanges })) @@ -37,7 +46,7 @@ vi.mock('./editor-external-watch-targets', () => ({ })) vi.mock('./editor-external-watch-event-reconciliation', () => ({ buildEditorExternalWatchEventHandler: vi.fn(() => ({ - handleFsChanged: vi.fn(), + handleFsChanged: subscriptionState.handleFsChanged, dispose: subscriptionState.disposeEventHandler })), collectOverflowEditorExternalReloadTargets: vi.fn() @@ -83,6 +92,8 @@ describe('useEditorExternalWatch subscriptions', () => { beforeEach(() => { vi.clearAllMocks() + subscriptionState.contacts.clear() + subscriptionState.subscribeRuntimeFileChanges.mockReset() subscriptionState.snapshot = { targets: [], targetsKey: '' } watchWorktree = vi.fn().mockResolvedValue(undefined) unwatchWorktree = vi.fn().mockResolvedValue(undefined) @@ -184,4 +195,44 @@ describe('useEditorExternalWatch subscriptions', () => { await act(async () => root.unmount()) expect(unsubscribeCurrent).toHaveBeenCalledTimes(1) }) + + it('retries a failed initial watch when the same host answers again', async () => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + subscriptionState.subscribeRuntimeFileChanges + .mockRejectedValueOnce(new Error('connection lost')) + .mockResolvedValueOnce(vi.fn()) + subscriptionState.snapshot = { targets: [runtimeTarget()], targetsKey: 'recovering-watch' } + try { + await act(async () => root.render(createElement(WatchProbe))) + await act(async () => subscriptionState.contacts.get('runtime-1')?.()) + expect(subscriptionState.subscribeRuntimeFileChanges).toHaveBeenCalledTimes(2) + expect(subscriptionState.handleFsChanged).toHaveBeenCalledWith( + { + worktreePath: '/runtime/repo', + events: [{ kind: 'overflow', absolutePath: '/runtime/repo' }] + }, + 'runtime-1' + ) + } finally { + warning.mockRestore() + } + }) + + it('replaces a lost watch and stops contact recovery when the editor unmounts', async () => { + const firstStop = vi.fn() + const secondStop = vi.fn() + subscriptionState.subscribeRuntimeFileChanges + .mockResolvedValueOnce(firstStop) + .mockResolvedValueOnce(secondStop) + subscriptionState.snapshot = { targets: [runtimeTarget()], targetsKey: 'live-watch' } + await act(async () => root.render(createElement(WatchProbe))) + await act(async () => subscriptionState.contacts.get('runtime-1')?.()) + expect(firstStop).toHaveBeenCalledOnce() + expect(subscriptionState.subscribeRuntimeFileChanges).toHaveBeenCalledTimes(2) + await act(async () => root.unmount()) + expect(secondStop).toHaveBeenCalledOnce() + expect(subscriptionState.unsubscribeContact).toHaveBeenCalledOnce() + subscriptionState.contacts.get('runtime-1')?.() + expect(subscriptionState.subscribeRuntimeFileChanges).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 9c7cce2826e..8eef0f5324f 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import { useAppStore } from '@/store' -import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client' +import { subscribeEditorRuntimeFileWatch } from './editor-runtime-file-watch' import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import type { FsChangedPayload } from '../../../shared/filesystem-entry-types' import { @@ -121,39 +121,19 @@ function subscribeRuntimeTarget( current: ((payload: FsChangedPayload, runtimeEnvironmentId?: string | null) => void) | null } ): void { - const key = getEditorExternalWatchTargetKey(target) - let cancelled = false - const pendingUnsubscribe = (): void => { - cancelled = true + const runtimeEnvironmentId = target.runtimeEnvironmentId + if (!runtimeEnvironmentId) { + return } - remoteWatchUnsubs.set(key, pendingUnsubscribe) - void subscribeRuntimeFileChanges( - { - settings: { activeRuntimeEnvironmentId: target.runtimeEnvironmentId! }, - worktreeId: target.worktreeId, - worktreePath: target.worktreePath, - connectionId: target.connectionId - }, - (payload) => fsChangedHandlerRef.current?.(payload, target.runtimeEnvironmentId), - (err) => warnExternalWatchFailure(target, err) + const key = getEditorExternalWatchTargetKey(target) + remoteWatchUnsubs.set( + key, + subscribeEditorRuntimeFileWatch( + { ...target, runtimeEnvironmentId }, + (payload) => fsChangedHandlerRef.current?.(payload, runtimeEnvironmentId), + (error) => warnExternalWatchFailure(target, error) + ) ) - .then((unsubscribe) => { - if (cancelled) { - unsubscribe() - return - } - if (remoteWatchUnsubs.get(key) === pendingUnsubscribe) { - remoteWatchUnsubs.set(key, unsubscribe) - } else { - unsubscribe() - } - }) - .catch((err) => { - if (remoteWatchUnsubs.get(key) === pendingUnsubscribe) { - remoteWatchUnsubs.delete(key) - } - warnExternalWatchFailure(target, err) - }) } export function verifyLatchedMoveDestinations( diff --git a/tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts b/tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts new file mode 100644 index 00000000000..6541bfb536c --- /dev/null +++ b/tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts @@ -0,0 +1,170 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { createRestartSession } from './helpers/orca-restart' +import { startOrcadConvertHost } from './helpers/orcad-convert-host' +import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' +import { convertAndRetain, serverCall } from './helpers/orcad-convert-flow' +import { waitForSessionReady, waitForStartupWorktreeRefresh } from './helpers/store' +import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' +import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' + +const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE +test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template') + +function captureRendererMessages(page: Page): void { + page.on('console', (message) => { + if ( + message.type() === 'error' || + message.type() === 'warning' || + message.text().startsWith('[file-change-event]') + ) { + console.log('[renderer-event]', message.text()) + } + }) +} + +for (const recovery of ['conversion', 'server restart'] as const) { + test(`remote editors receive host file changes after ${recovery}`, async (// oxlint-disable-next-line no-empty-pattern -- This exercise owns its app launch. + {}, testInfo) => { + test.setTimeout(4 * 60_000) + const host = startOrcadConvertHost('docker', testInfo) + const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) + let app: ElectronApplication | null = null + try { + if (!host.exec) { + throw new Error('Docker controls are unavailable') + } + const execute = host.exec + const first = await session.launch() + app = first.app + await waitForSessionReady(first.page) + await session.close(app) + app = null + const marker = `REMOTE_MARKDOWN_${Date.now()}` + execute( + `printf '# ${marker}\n\nHost-owned Markdown.\n\n[[LATE]]\n' > '${host.remoteRepoPath}/README.md'` + ) + const seeded = seedRelayEraProfile(session.userDataDir, host.input, { + repoPath: host.remoteRepoPath, + folderPath: host.remoteFolderPath + }) + const launched = await session.launch() + app = launched.app + let page = launched.page + captureRendererMessages(page) + await waitForSessionReady(page) + await convertAndRetain(page, session.userDataDir, seeded) + const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( + (e) => e.orcadDeployment?.sshTargetId === seeded.targetId + ) + if (!environment) { + throw new Error('Missing managed environment') + } + if (recovery === 'server restart') { + await session.close(app) + app = null + const restarted = await session.launch() + app = restarted.app + page = restarted.page + captureRendererMessages(page) + await waitForSessionReady(page) + await waitForStartupWorktreeRefresh(page) + } + await page.evaluate( + ({ id, hostId }) => window.__store?.getState().setActiveWorktree(id, hostId), + { + id: seeded.worktreeId, + hostId: toRuntimeExecutionHostId(environment.id) + } + ) + await dismissTransientAnnouncement(page) + await expect(page.locator('.rich-markdown-editor')).toContainText(marker, { timeout: 30_000 }) + await page.evaluate(() => { + window.addEventListener('orca:worktree-file-change', (event) => { + if (event instanceof CustomEvent) { + console.log('[file-change-event]', JSON.stringify(event.detail)) + } + }) + }) + if (recovery === 'server restart') { + const ready = page.waitForEvent('console', { + predicate: (message) => + message.text().startsWith('[file-change-event]') && + message.text().includes('WATCH_READY.txt'), + timeout: 30_000 + }) + execute(`printf 'ready\\n' > '${host.remoteRepoPath}/WATCH_READY.txt'`) + await ready + const serverPids = (): string => + execute( + 'for f in /root/.orca-remote/orcad-*/.orcad-pid; do pid=$(cat "$f" 2>/dev/null) && kill -0 "$pid" 2>/dev/null && echo "$pid"; done; true' + ).trim() + const before = serverPids() + expect(before).toMatch(/^\d+$/) + execute('kill -TERM $(cat /root/.orca-remote/orcad-*/.orcad-pid)') + await expect.poll(serverPids, { timeout: 30_000 }).toBe('') + console.log('[managed-server-terminated]', before) + } + const updatedMarker = `UPDATED_${marker}` + execute( + `printf '# ${updatedMarker}\\n\\nChanged independently on the execution host.\\n' > '${host.remoteRepoPath}/README.md'` + ) + if (recovery === 'server restart') { + await expect + .poll( + () => + page.evaluate( + async ({ id, worktreeId }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: id, + method: 'files.read', + params: { worktree: `id:${worktreeId}`, relativePath: 'README.md' } + }) + return response.ok ? JSON.stringify(response.result) : '' + }, + { id: environment.id, worktreeId: seeded.worktreeId } + ), + { timeout: 180_000 } + ) + .toContain(updatedMarker) + console.log( + '[managed-server-restarted]', + execute('cat /root/.orca-remote/orcad-*/.orcad-pid').trim() + ) + } + const content = await serverCall(page, environment.id, 'files.read', { + worktree: `id:${seeded.worktreeId}`, + relativePath: 'README.md' + }) + console.log('[host-updated-content]', content) + expect(content).toContain(updatedMarker) + await page.screenshot({ path: testInfo.outputPath('remote-editor-observed.png') }) + await expect(page.locator('.rich-markdown-editor')).toContainText(updatedMarker, { + timeout: 15_000 + }) + await page.screenshot({ path: testInfo.outputPath('remote-editor-refreshed.png') }) + if (recovery === 'server restart') { + const resumed = page.waitForEvent('console', { + predicate: (message) => + message.text().startsWith('[file-change-event]') && + message.text().includes('WATCH_RECOVERED.txt'), + timeout: 15_000 + }) + execute(`printf 'watcher recovered\\n' > '${host.remoteRepoPath}/WATCH_RECOVERED.txt'`) + const created = await serverCall(page, environment.id, 'files.read', { + worktree: `id:${seeded.worktreeId}`, + relativePath: 'WATCH_RECOVERED.txt' + }) + console.log('[host-post-restart-file]', created) + expect(created).toContain('watcher recovered') + await resumed + } + } finally { + if (app) { + await session.close(app) + } + await session.dispose() + host.cleanup() + } + }) +}