From 8167ddaa7fcf011e4f3aff32d9c61336783f986e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:07:48 -0700 Subject: [PATCH] Distinguish remote browser failures from server connection failures (#26573) * Distinguish remote browser service failures from server outages * Use the shared browser_unavailable code in the stream failure classifier --------- Co-authored-by: m4air --- .github/workflows/e2e.yml | 7 +- config/scripts/ci-e2e-job-selection.mjs | 8 +- config/scripts/ci-e2e-job-selection.test.mjs | 11 ++ config/scripts/pr-e2e-source-routing.mjs | 9 + .../remote-browser-service-failure.test.ts | 62 +++++++ .../remote-browser-stream-errors.test.ts | 34 +++- .../remote-browser-stream-errors.ts | 33 +++- .../remote-browser-stream-lifecycle.ts | 55 +++--- .../remote-browser-stream-restart-attempt.ts | 4 +- .../remote-browser-stream-status.ts | 7 + src/renderer/src/i18n/locales/en.json | 1 + .../ssh-orcad-browser-service-status.spec.ts | 173 ++++++++++++++++++ 12 files changed, 357 insertions(+), 47 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/stream-remote/remote-browser-service-failure.test.ts create mode 100644 tests/e2e/ssh-orcad-browser-service-status.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2969078a99a..24c0c0859aa 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -351,7 +351,8 @@ jobs: . != "tests/e2e/ssh-orcad-auto-convert.spec.ts" and . != "tests/e2e/windows-missing-appdata-startup.spec.ts" and . != "tests/e2e/ssh-orcad-idle-exit.spec.ts" and - . != "tests/e2e/ssh-orcad-browser-capabilities.spec.ts" + . != "tests/e2e/ssh-orcad-browser-capabilities.spec.ts" and + . != "tests/e2e/ssh-orcad-browser-service-status.spec.ts" )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" fi mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs" @@ -585,7 +586,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') + 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') runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -621,7 +622,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 --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 --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 3a03b5a7daa..e799a1d5e8e 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -53,6 +53,8 @@ export const LAYOUT_ORACLE_HEADLESS_E2E_SPEC = 'tests/e2e/workspace-layout-oracl export const ORCAD_IDLE_EXIT_E2E_SPEC = 'tests/e2e/ssh-orcad-idle-exit.spec.ts' export const ORCAD_BROWSER_CAPABILITIES_E2E_SPEC = 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts' +export const ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC = + 'tests/e2e/ssh-orcad-browser-service-status.spec.ts' export const DEDICATED_E2E_SPECS = [ ...DOCKER_SSH_E2E_SPECS, NODE_NETWORK_E2E_SPEC, @@ -63,7 +65,8 @@ export const DEDICATED_E2E_SPECS = [ ORCAD_AUTO_CONVERT_E2E_SPEC, WINDOWS_MISSING_APPDATA_E2E_SPEC, ORCAD_IDLE_EXIT_E2E_SPEC, - ORCAD_BROWSER_CAPABILITIES_E2E_SPEC + ORCAD_BROWSER_CAPABILITIES_E2E_SPEC, + ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS) @@ -106,7 +109,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') { spec === LAYOUT_ORACLE_HEADLESS_E2E_SPEC || spec === ORCAD_AUTO_CONVERT_E2E_SPEC || spec === ORCAD_IDLE_EXIT_E2E_SPEC || - spec === ORCAD_BROWSER_CAPABILITIES_E2E_SPEC + spec === ORCAD_BROWSER_CAPABILITIES_E2E_SPEC || + spec === ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC ) } } diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 447170c9d12..701c7461fc2 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -21,6 +21,17 @@ it.each([ expect(selectPrE2eSpecs([path])).toContain('tests/e2e/ssh-orcad-browser-capabilities.spec.ts') }) +it.each(['errors', 'status', 'lifecycle', 'restart-attempt'])( + 'routes the real browser service status check from %s changes', + (name) => { + expect( + selectPrE2eSpecs([ + `src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-${name}.ts` + ]) + ).toContain('tests/e2e/ssh-orcad-browser-service-status.spec.ts') + } +) + const workflow = parse(readFileSync('.github/workflows/e2e.yml', 'utf8')) const prWorkflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const classify = (specs, ssh = 'false') => classifyE2eJobs(JSON.stringify(specs), ssh) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index e228d9fbb5f..ae55adce823 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -55,6 +55,15 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) }, + { + id: 'browser.orcad-service-status', + specs: ['tests/e2e/ssh-orcad-browser-service-status.spec.ts'], + matches: (file) => + isProductSource(file) && + /^src\/renderer\/src\/components\/browser-pane\/stream-remote\/remote-browser-stream-(?:errors|status|lifecycle|restart-attempt)\.ts$/.test( + file + ) + }, { id: 'ssh.orcad-idle-exit', specs: ['tests/e2e/ssh-orcad-idle-exit.spec.ts'], diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-service-failure.test.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-service-failure.test.ts new file mode 100644 index 00000000000..b384a169582 --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-service-failure.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createHarness, + openStreamAndConfirmReady, + rpcError, + settle +} from './remote-browser-stream-lifecycle-test-harness' + +const SERVICE_NOTICE = 'The remote browser is unavailable. Check its setup on the server.' + +describe('browser service failures on a responding remote host', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('shows the browser setup failure on initial open and keeps manual recovery', async () => { + const harness = createHarness() + harness.failEverySubscribe(rpcError('browser_unavailable', 'unconfigured backend')) + harness.lifecycle.open() + await settle() + expect(harness.currentError).toBe(SERVICE_NOTICE) + expect(harness.currentStatusKind).toBe('stopped') + expect(harness.reconnectOffered).toBe(true) + }) + + it('keeps browser service recovery bounded and restores the stream once it returns', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.failEverySubscribe(rpcError('browser_unavailable', 'backend exited')) + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + expect(harness.currentError).toBe(SERVICE_NOTICE) + expect(harness.reconnectOffered).toBe(false) + harness.failEverySubscribe(null) + await vi.advanceTimersByTimeAsync(1_000) + harness.streams.at(-1)?.emitReady() + await settle() + expect(harness.currentStatusKind).toBe('live') + expect(harness.currentError).toBeNull() + }) + + it('offers manual recovery with the service notice after retry exhaustion', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.failEverySubscribe(rpcError('browser_unavailable', 'backend remains unavailable')) + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(60_000) + expect(harness.currentError).toBe(SERVICE_NOTICE) + expect(harness.reconnectOffered).toBe(true) + expect(harness.subscribeAttempts).toBeLessThanOrEqual(6) + }) + + it('keeps the service failure specific when resizing replaces a live stream', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.setViewportSize({ width: 1_024, height: 768 }) + harness.failEverySubscribe(rpcError('browser_unavailable', 'backend exited')) + harness.lifecycle.restartForViewport('page-1') + await settle() + expect(harness.currentError).toBe(SERVICE_NOTICE) + expect(harness.reconnectOffered).toBe(true) + }) +}) diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts index 9dcf0cfccea..9cff39d017f 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { isPermanentRemoteBrowserStreamFailure, remoteBrowserStreamUnsupportedError, - resolveRemoteBrowserStreamRestartFailure + resolveRemoteBrowserStreamFailure } from './remote-browser-stream-errors' function rpcError(code: string, message = 'boom'): Error { @@ -13,6 +13,28 @@ function rpcError(code: string, message = 'boom'): Error { // was enforced by nothing — re-adding `selector_not_found` (the exact code 08260a54bf had to walk // back out) left the whole suite green, as did deleting two of the three members. describe('remote browser stream failure classification', () => { + it.each(['opening', 'restart'] as const)( + 'distinguishes browser setup from transport during %s', + (phase) => { + const failure = resolveRemoteBrowserStreamFailure( + rpcError('browser_unavailable', 'unconfigured backend'), + phase + ) + expect(failure.message).toBe( + 'The remote browser is unavailable. Check its setup on the server.' + ) + expect(failure.shouldRetry).toBe(true) + expect(failure.logRawError).toBe(true) + expect(isPermanentRemoteBrowserStreamFailure(rpcError('browser_unavailable'))).toBe(false) + } + ) + + it('keeps the initial transport failure distinct from a lost stream', () => { + expect(resolveRemoteBrowserStreamFailure(rpcError('runtime_timeout'), 'opening').message).toBe( + 'Cannot reach the remote server.' + ) + }) + it('treats only codes that prove the target is gone as permanent', () => { for (const code of [ 'worktree_not_found_on_server', @@ -30,9 +52,7 @@ describe('remote browser stream failure classification', () => { // stopped retries on a blip and stranded the pane, which is the bug this area exists to prevent. it('does not treat an unresolved selector as proof the target is gone', () => { expect(isPermanentRemoteBrowserStreamFailure(rpcError('selector_not_found'))).toBe(false) - expect( - resolveRemoteBrowserStreamRestartFailure(rpcError('selector_not_found')).shouldRetry - ).toBe(true) + expect(resolveRemoteBrowserStreamFailure(rpcError('selector_not_found')).shouldRetry).toBe(true) }) it('keeps unknown and transport failures retryable', () => { @@ -44,7 +64,7 @@ describe('remote browser stream failure classification', () => { }) it('keeps a permanent failure specific and replaces only raw transient text', () => { - const permanent = resolveRemoteBrowserStreamRestartFailure( + const permanent = resolveRemoteBrowserStreamFailure( rpcError('worktree_not_found_on_server', 'worktree is gone') ) // Its own message says something true that a generic "lost connection" would not. @@ -52,7 +72,7 @@ describe('remote browser stream failure classification', () => { expect(permanent.shouldRetry).toBe(false) expect(permanent.logRawError).toBe(false) - const transient = resolveRemoteBrowserStreamRestartFailure( + const transient = resolveRemoteBrowserStreamFailure( rpcError('runtime_unavailable', 'Runtime environment is manually disconnected.') ) expect(transient.message).toBe('Lost connection to the remote server.') @@ -62,7 +82,7 @@ describe('remote browser stream failure classification', () => { }) it('uses localized copy for permanent failures without an Error message', () => { - const failure = resolveRemoteBrowserStreamRestartFailure({ + const failure = resolveRemoteBrowserStreamFailure({ code: 'worktree_not_found_on_server' }) diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.ts index 6e37bb3021a..121b47fc0cc 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.ts @@ -1,8 +1,11 @@ import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' +import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../../../../shared/runtime-session-contracts' import { remoteBrowserStreamLostNotice, remoteBrowserStreamRestartFailedNotice, - remoteBrowserStreamUnsupportedNotice + remoteBrowserStreamUnsupportedNotice, + remoteBrowserStreamUnreachableNotice, + remoteBrowserServiceUnavailableNotice } from './remote-browser-stream-status' // Why: a runtime lacking browser.screencast.v1 will not grow it while this connection lives, so @@ -44,7 +47,7 @@ function readErrorCode(error: unknown): string | null { if (!error || typeof error !== 'object' || !('code' in error)) { return null } - const code = (error as { code: unknown }).code + const code = error.code return typeof code === 'string' ? code : null } @@ -68,7 +71,7 @@ export function isPermanentRemoteBrowserStreamFailure(error: unknown): boolean { ) } -export type RemoteBrowserStreamRestartFailure = { +export type RemoteBrowserStreamFailure = { /** What the pane shows. */ message: string /** False once the failure is proven unrecoverable on this connection. */ @@ -89,9 +92,18 @@ export type RemoteBrowserStreamRestartFailure = { // is what stranded the pane in the first place. `selector_not_found` already had to be walked back // out of the permanent set once (08260a54bf) — proof this classification can be wrong — so a // misjudgement here must stay recoverable by hand. -export function resolveRemoteBrowserStreamRestartFailure( - error: unknown -): RemoteBrowserStreamRestartFailure { +export function resolveRemoteBrowserStreamFailure( + error: unknown, + phase: 'opening' | 'restart' = 'restart' +): RemoteBrowserStreamFailure { + // Browser setup can recover without the server connection changing. + if (readErrorCode(error) === BROWSER_UNAVAILABLE_ERROR_CODE) { + return { + message: remoteBrowserServiceUnavailableNotice(), + shouldRetry: true, + logRawError: true + } + } if (isPermanentRemoteBrowserStreamFailure(error)) { return { message: error instanceof Error ? error.message : remoteBrowserStreamRestartFailedNotice(), @@ -99,5 +111,12 @@ export function resolveRemoteBrowserStreamRestartFailure( logRawError: false } } - return { message: remoteBrowserStreamLostNotice(), shouldRetry: true, logRawError: true } + return { + message: + phase === 'opening' + ? remoteBrowserStreamUnreachableNotice() + : remoteBrowserStreamLostNotice(), + shouldRetry: true, + logRawError: true + } } diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts index c32929b2e1d..0dabac044d5 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts @@ -11,14 +11,14 @@ import { remoteBrowserStreamLostNotice, remoteBrowserStreamRetrying, remoteBrowserStreamStopped, - remoteBrowserStreamUnreachableNotice, - REMOTE_BROWSER_STREAM_IDLE + REMOTE_BROWSER_STREAM_IDLE, + remoteBrowserStreamNotice, + type RemoteBrowserStreamStatus } from './remote-browser-stream-status' import { - isPermanentRemoteBrowserStreamFailure, isRemoteBrowserPageMissingError, remoteBrowserStreamUnsupportedError, - resolveRemoteBrowserStreamRestartFailure + resolveRemoteBrowserStreamFailure } from './remote-browser-stream-errors' import { areRemoteViewportSizesNear, @@ -40,6 +40,7 @@ export class RemoteBrowserStreamLifecycle { private subscription: RemoteBrowserStreamSubscription | null = null private streamViewportSize: RemoteBrowserViewportSize | null = null private readonly liveness = new RemoteBrowserStreamLiveness() + private status: RemoteBrowserStreamStatus = REMOTE_BROWSER_STREAM_IDLE constructor(private readonly deps: RemoteBrowserStreamLifecycleDeps) { this.tokens = new RemoteBrowserOperationTokens(deps.identity) @@ -48,10 +49,19 @@ export class RemoteBrowserStreamLifecycle { // each restart subscribes fine, then the stream ends before 'ready', so the catch that normally // reports never runs. 'stopped' carries its notice, so that case cannot go silent. this.restartScheduler = new RemoteBrowserStreamRestartScheduler(undefined, () => - deps.setStatus(remoteBrowserStreamStopped(remoteBrowserStreamLostNotice())) + this.setStatus( + remoteBrowserStreamStopped( + remoteBrowserStreamNotice(this.status) ?? remoteBrowserStreamLostNotice() + ) + ) ) } + private setStatus(status: RemoteBrowserStreamStatus): void { + this.status = status + this.deps.setStatus(status) + } + forgetStreamViewportSize(): void { this.streamViewportSize = null } @@ -62,11 +72,11 @@ export class RemoteBrowserStreamLifecycle { const { tokens, deps } = this // A reopen (tab switch, environment or worktree change, or Reconnect) starts a fresh budget, so // 'opening' replaces whatever the previous attempt ended on — including a spent 'stopped'. - deps.setStatus(REMOTE_BROWSER_STREAM_OPENING) + this.setStatus(REMOTE_BROWSER_STREAM_OPENING) this.retireInFlightWork()?.unsubscribe() const operationToken = tokens.createOperationToken() if (!operationToken) { - deps.setStatus(REMOTE_BROWSER_STREAM_IDLE) + this.setStatus(REMOTE_BROWSER_STREAM_IDLE) return () => {} } void this.session @@ -101,8 +111,8 @@ export class RemoteBrowserStreamLifecycle { // Why classified here too: the same condition (a host that cannot stream) reaches both this // path and the restart path, and it must not read as "unreachable" here and as its own // specific message there. - const permanent = isPermanentRemoteBrowserStreamFailure(error) - if (!permanent) { + const failure = resolveRemoteBrowserStreamFailure(error, 'opening') + if (failure.logRawError) { console.warn('[browser-pane] remote browser failed to open:', error) } // Why this can be the loser of a race: the stream token is claimed before subscribe is @@ -113,15 +123,8 @@ export class RemoteBrowserStreamLifecycle { if (this.restartScheduler.isScheduled) { return } - // "Unreachable" rather than "lost": nothing was ever established here. And this path never - // had a stream, so the retry budget never runs — 'stopped' is what hands the user a way back. - deps.setStatus( - remoteBrowserStreamStopped( - permanent && error instanceof Error - ? error.message - : remoteBrowserStreamUnreachableNotice() - ) - ) + // An initial open has no retry budget; preserve manual recovery. + this.setStatus(remoteBrowserStreamStopped(failure.message)) }) return () => { cancelled = true @@ -151,7 +154,7 @@ export class RemoteBrowserStreamLifecycle { // Why: without a token the .then/.catch below would mutate busy/remoteError on behalf of a // restart that a newer operation has already replaced. const restartToken = this.tokens.createOperationToken(pageId) - this.deps.setStatus(REMOTE_BROWSER_STREAM_OPENING) + this.setStatus(REMOTE_BROWSER_STREAM_OPENING) current.unsubscribe() void this.startStream(pageId) .then((subscription) => { @@ -177,12 +180,12 @@ export class RemoteBrowserStreamLifecycle { // Why classified rather than forwarded: this was the one failure path still putting raw // transport text in the UI ("Runtime environment pairing changed; refresh and try again"), // which is written for logs and names our internals. The other two paths already classify. - const failure = resolveRemoteBrowserStreamRestartFailure(error) + const failure = resolveRemoteBrowserStreamFailure(error) if (failure.logRawError) { console.warn('[browser-pane] remote stream resize failed:', error) } // A resize during a blip tears down a live subscription and never reaches the budget. - this.deps.setStatus(remoteBrowserStreamStopped(failure.message)) + this.setStatus(remoteBrowserStreamStopped(failure.message)) }) } @@ -279,7 +282,7 @@ export class RemoteBrowserStreamLifecycle { // 'live' carries no notice, so a toast the retry already healed cannot survive // (STA-3483). this.liveness.markReady() - deps.setStatus(REMOTE_BROWSER_STREAM_LIVE) + this.setStatus(REMOTE_BROWSER_STREAM_LIVE) deps.applyTabInfo(event.tab) void deps.syncViewport(event.browserPageId).catch(() => {}) }, @@ -288,7 +291,7 @@ export class RemoteBrowserStreamLifecycle { // close below is deliberately non-restarting, so nothing else would hand the user a way // back. Its message is the host's own, which is more specific than any substitute. onFailed: (message) => { - deps.setStatus(remoteBrowserStreamStopped(message)) + this.setStatus(remoteBrowserStreamStopped(message)) this.handleStreamClosed(token, false) }, // Why 'stopped' and not 'retrying': a transport error is NOT guaranteed to be followed by @@ -306,7 +309,7 @@ export class RemoteBrowserStreamLifecycle { // does follow must still refill the budget for a stream that had been healthy. onTransportError: () => { this.liveness.stopWaitingForReady() - deps.setStatus(remoteBrowserStreamStopped(remoteBrowserStreamLostNotice())) + this.setStatus(remoteBrowserStreamStopped(remoteBrowserStreamLostNotice())) }, onPageMissing: () => deps.closeMissingRemotePage(pageId), onFrame: (bytes) => deps.handleFrameBytes(token, bytes), @@ -335,7 +338,7 @@ export class RemoteBrowserStreamLifecycle { // Why no notice yet: the budget exists to absorb a blip invisibly, so nothing is said until an // attempt has actually failed. A non-restarting close leaves whatever the caller published. if (restart) { - this.deps.setStatus(remoteBrowserStreamRetrying(null)) + this.setStatus(remoteBrowserStreamRetrying(null)) } const current = this.subscription this.subscription = null @@ -362,7 +365,7 @@ export class RemoteBrowserStreamLifecycle { createRemoteBrowserStreamRestartAttempt(token, { tokens: this.tokens, session: this.session, - setStatus: (status) => this.deps.setStatus(status), + setStatus: (status) => this.setStatus(status), applyTabInfo: (tab) => this.deps.applyTabInfo(tab), closeMissingRemotePage: (remotePageId) => this.deps.closeMissingRemotePage(remotePageId), startStream: (pageId) => this.startStream(pageId), diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-attempt.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-attempt.ts index ac129875a9e..6d925119383 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-attempt.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-attempt.ts @@ -1,7 +1,7 @@ import type { RemoteBrowserPageSession } from './remote-browser-page-session' import { isRemoteBrowserPageMissingError, - resolveRemoteBrowserStreamRestartFailure + resolveRemoteBrowserStreamFailure } from './remote-browser-stream-errors' import { remoteBrowserStreamRetrying, @@ -63,7 +63,7 @@ export function createRemoteBrowserStreamRestartAttempt( deps.closeMissingRemotePage(token.remotePageId) return false } - const failure = resolveRemoteBrowserStreamRestartFailure(error) + const failure = resolveRemoteBrowserStreamFailure(error) if (failure.logRawError) { // The raw text is transport-level and written for logs; keep it out of the UI but not out of // reach, since nothing else records it. diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.ts index 5d1bd31744d..e2842df8694 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.ts @@ -80,6 +80,13 @@ export function remoteBrowserStreamUnreachableNotice(): string { ) } +export function remoteBrowserServiceUnavailableNotice(): string { + return translate( + 'auto.components.BrowserPane.streamBrowserUnavailable', + 'The remote browser is unavailable. Check its setup on the server.' + ) +} + export function remoteBrowserStreamRestartFailedNotice(): string { return translate( 'auto.components.BrowserPane.streamRestartFailed', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e47618a1c2b..2c044839f22 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17652,6 +17652,7 @@ "BrowserPane": { "streamConnectionLost": "Lost connection to the remote server.", "streamConnectionUnreachable": "Cannot reach the remote server.", + "streamBrowserUnavailable": "The remote browser is unavailable. Check its setup on the server.", "streamRestartFailed": "Failed to restart remote browser stream.", "streamCapabilityUnsupported": "The selected runtime does not support remote browser streaming." }, diff --git a/tests/e2e/ssh-orcad-browser-service-status.spec.ts b/tests/e2e/ssh-orcad-browser-service-status.spec.ts new file mode 100644 index 00000000000..3cfb16d87d5 --- /dev/null +++ b/tests/e2e/ssh-orcad-browser-service-status.spec.ts @@ -0,0 +1,173 @@ +import { cpSync, rmSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { createRestartSession } from './helpers/orca-restart' +import { waitForSessionReady } from './helpers/store' +import { + cleanupDockerSshRelayTarget, + startDockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' +import { + startSshRemoteOnlyBrowserFixture, + SSH_REMOTE_ONLY_ORIGIN, + SSH_REMOTE_ONLY_COOKIE_NAME, + SSH_REMOTE_ONLY_COOKIE_VALUE +} from './helpers/ssh-remote-only-browser-fixture' +import { createRetentionFixtureDirectory } from './helpers/host-created-terminal-retention-oracle' +import { managedServer, reconnect, serverCall } from './helpers/orcad-convert-flow' +import { navigateGuest } from './helpers/browser-split-guest-probes' + +const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE +test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template') + +async function guestMarker(page: Page, tabId: string): Promise { + return page.evaluate(async (id) => { + const guest = document.querySelector( + `[data-browser-overlay-tab-id="${id}"] webview` + ) + try { + return await guest?.executeJavaScript('document.querySelector("#marker")?.textContent') + } catch { + return null + } + }, tabId) +} + +test('an unavailable browser on a responding managed host does not report a server outage', async (// oxlint-disable-next-line no-empty-pattern -- Owns app launch. +{}, testInfo) => { + test.setTimeout(5 * 60_000) + const target = startDockerSshRelayTarget(testInfo) + const scratch = createRetentionFixtureDirectory() + const template = path.join(scratch, 'template') + const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: template }) + let app: ElectronApplication | null = null + try { + startSshRemoteOnlyBrowserFixture(target) + const launched = await session.launch() + app = launched.app + const page = launched.page + page.on('console', (message) => { + if (message.type() === 'warning' || message.type() === 'error') { + console.log('[browser-renderer]', message.text().slice(0, 1_000)) + } + }) + await waitForSessionReady(page) + const remote = await connectDockerSshRelayTarget(page, target, { seedInitialTab: false }) + expect(await managedServer(page, remote.targetId)).toMatchObject({ kind: 'relay' }) + const tabId = await page.evaluate( + ({ worktreeId, url }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Missing store') + } + const tab = state.createBrowserTab(worktreeId, url, { + title: 'Retained browser', + activate: true + }) + for (const terminal of state.tabsByWorktree[worktreeId] ?? []) { + state.closeTab(terminal.id) + } + return tab.id + }, + { worktreeId: remote.worktreeId, url: `${SSH_REMOTE_ONLY_ORIGIN}/login` } + ) + await expect.poll(() => guestMarker(page, tabId), { timeout: 60_000 }).toBe('login-marker') + await navigateGuest(page, tabId, `${SSH_REMOTE_ONLY_ORIGIN}/echo/before`) + const cookieMarker = `cookie:${SSH_REMOTE_ONLY_COOKIE_NAME}=${SSH_REMOTE_ONLY_COOKIE_VALUE}` + await expect.poll(() => guestMarker(page, tabId), { timeout: 30_000 }).toBe(cookieMarker) + await page.screenshot({ path: testInfo.outputPath('browser-before-conversion.png') }) + await expect + .poll( + () => + page.evaluate((id) => window.api.pty.listSessions({ connectionId: id }), remote.targetId), + { timeout: 30_000 } + ) + .toEqual([]) + cpSync(TEMPLATE!, template, { recursive: true }) + const server = await reconnect(page, remote.targetId) + console.log('[browser-conversion-connect]', server) + expect(JSON.parse(server)).toMatchObject({ kind: 'managed' }) + const environments = await page.evaluate(() => window.api.runtimeEnvironments.list()) + const environment = environments.find( + (entry) => entry.orcadDeployment?.sshTargetId === remote.targetId + ) + if (!environment) { + throw new Error('Missing managed environment') + } + console.log('[browser-host-status]', await serverCall(page, environment.id, 'status.get')) + await expect + .poll( + () => + page.evaluate((worktreeId) => { + const state = window.__store?.getState() + return ( + state?.repos.find((repo) => + (state.worktreesByRepo[repo.id] ?? []).some( + (worktree) => worktree.id === worktreeId + ) + )?.executionHostId ?? null + ) + }, remote.worktreeId), + { timeout: 60_000 } + ) + .toMatch(/^runtime:/) + const retained = await page.evaluate((worktreeId) => { + const state = window.__store?.getState() + const tab = state?.browserTabsByWorktree[worktreeId]?.[0] + const repo = state?.repos.find((repo) => + (state.worktreesByRepo[repo.id] ?? []).some((worktree) => worktree.id === worktreeId) + ) + if (repo?.executionHostId) { + state?.setActiveWorktree(worktreeId, repo.executionHostId) + } + if (tab?.activePageId) { + state?.focusBrowserTabInWorktree(worktreeId, tab.activePageId, { surfacePane: true }) + } + return tab?.id ?? null + }, remote.worktreeId) + expect(retained).toBe(tabId) + const notice = page.getByTestId('remote-browser-stream-error') + await expect(notice).toBeVisible({ timeout: 30_000 }) + await page.screenshot({ path: testInfo.outputPath('browser-service-status.png') }) + console.log('[browser-service-notice]', await notice.innerText()) + const browserReply = await page.evaluate( + (environmentId) => + window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'browser.tabList', + params: {} + }), + environment.id + ) + expect(browserReply).toMatchObject({ ok: false, error: { code: 'browser_unavailable' } }) + console.log('[browser-service-refusal]', JSON.stringify(browserReply)) + const catalog = JSON.parse(await serverCall(page, environment.id, 'repo.list')) + console.log('[browser-service-responsive-host]', JSON.stringify(catalog)) + expect(catalog).toMatchObject({ + ok: true, + result: { repos: expect.arrayContaining([expect.objectContaining({ id: remote.repoId })]) } + }) + await expect(notice).toContainText( + 'The remote browser is unavailable. Check its setup on the server.' + ) + await expect(notice).not.toContainText('Cannot reach the remote server.') + await notice.getByRole('button', { name: 'Reconnect', exact: true }).click() + await expect(notice).toContainText( + 'The remote browser is unavailable. Check its setup on the server.' + ) + console.log('[browser-service-after-retry]', await notice.innerText()) + expect(JSON.parse(await serverCall(page, environment.id, 'repo.list'))).toMatchObject({ + ok: true, + result: { repos: expect.arrayContaining([expect.objectContaining({ id: remote.repoId })]) } + }) + } finally { + if (app) { + await session.close(app) + } + await session.dispose() + cleanupDockerSshRelayTarget(target) + rmSync(scratch, { recursive: true, force: true }) + } +})