mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Fix SSH port-forward rows disappearing after hydration (#11713)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-29",
|
||||
"updatedAt": "2026-07-30",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -7879,6 +7879,139 @@
|
||||
"Relay-hosted plugin provisioning, consent persistence, workers, and audit services remain out of scope and the relay registration therefore denies every provisioned identity by default."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if the suite flakes, permits a mismatched terminal send, performs more than one inventory list per call, accepts client-supplied grants, or relay and desktop error codes diverge."
|
||||
},
|
||||
{
|
||||
"id": "ssh-port-forward.renderer-snapshot-continuity",
|
||||
"title": "SSH forwarded-port state survives stale renderer hydration",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "desktop-ssh",
|
||||
"layer": "renderer-ssh-snapshot-reconciliation",
|
||||
"surfaces": [
|
||||
"SSH Ports panel",
|
||||
"forwarded-port renderer state",
|
||||
"persisted forward restoration",
|
||||
"ssh2 port forwarding",
|
||||
"system-SSH port forwarding"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["ssh2", "system-ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["ssh2", "system-ssh"],
|
||||
"coverageNotes": "A deterministic renderer ordering test covers stale initial snapshots independently by target and stream, including hydration after partial SSH authority reconciliation. Headed macOS Electron tests force the same startup hydration race and use an ephemeral Docker sshd, a real remote Git worktree, real remote Node listeners, real HTTP forwards, in-place relay and full transport reconnect restoration, collision rejection, scan refresh, explicit removal, and an unrelated surviving forward through both transports. The forced-system run requires a recorded OpenSSH -L invocation.",
|
||||
"motivatingLinks": ["user-reported SSH Ports panel disappearance"],
|
||||
"invariant": "For one connected SSH authority, a renderer snapshot may update Forwarded or Detected state only if no newer push for that target and stream arrived after the snapshot began. Active tunnels and persisted intent remain authoritative through scan refresh and reconnect until explicit removal or a real connection-lifecycle transition.",
|
||||
"oracle": "Hold empty initial Forwarded and Detected snapshot promises, publish live events, release the snapshots, and require each target and stream to preserve only its own newer push while applying unaffected snapshots. Reject one target's Detected snapshot and require its Forwarded snapshot plus later targets to hydrate independently. Begin another target with partial connected authority and require its snapshots to hydrate after same-watermark authority reconciliation. In headed Electron, hold an authoritative empty Forwarded snapshot across renderer reload, add a forward through the Ports panel, release and confirm the wrapped main handler resumed, then complete a later renderer-to-main listPortForwards round trip as the ordered hydration-continuation barrier. Before checking the Forwarded row, require main inventory, persisted intent, HTTP, remote process identity, and close warnings to prove the tunnel remained authoritative. Then forward two real Docker listeners, refresh detection, force an in-place relay-channel reconnect, perform a full transport reconnect, reject a bound local-port collision, remove one row, and require all signals to agree.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot",
|
||||
"pnpm exec electron-vite build --mode e2e",
|
||||
"ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/hooks/useIpcEvents.test.ts",
|
||||
"src/main/ssh/ssh-port-forward.test.ts",
|
||||
"src/main/ssh/system-ssh-forward-process.test.ts",
|
||||
"src/main/ipc/ssh.test.ts",
|
||||
"src/main/ssh/ssh-relay-session.test.ts",
|
||||
"tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts",
|
||||
"tests/e2e/ssh-port-forward-lifecycle.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/hooks/useIpcEvents.test.ts",
|
||||
"assertions": ["does not let initial SSH port snapshots overwrite newer push events"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-port-forward.test.ts",
|
||||
"assertions": ["lists forwards filtered by connectionId", "removes a forward by id"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/system-ssh-forward-process.test.ts",
|
||||
"assertions": [
|
||||
"does not spawn ssh when the requested local forward port is already in use",
|
||||
"sends SIGTERM then SIGKILL when the process does not exit",
|
||||
"does not resolve stop until the process exits"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/ssh.test.ts",
|
||||
"assertions": [
|
||||
"preserves active port forwards and live connections across handler re-registration",
|
||||
"persists desired forwards and broadcasts when an active forward closes unexpectedly"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-relay-session.test.ts",
|
||||
"assertions": ["cleans up port forwards on reconnect"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts",
|
||||
"assertions": ["holds only the first matching request while its snapshot is unresolved"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/ssh-port-forward-lifecycle.spec.ts",
|
||||
"assertions": ["keeps a user-forwarded listener live across scan refresh @headful"]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 6.77,
|
||||
"summary": "Six renderer, main-process, and barrier lifecycle files passed with 215 tests, including deterministic stale-snapshot, rejected-stream, partial-authority, and single-capture isolation plus existing collision, delayed-exit, reconnect, persistence, re-registration, and unrelated-forward contracts."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 53.7,
|
||||
"summary": "Headed Electron passed against a real Docker sshd over ssh2 after forcing the stale startup-snapshot race and exact renderer-continuation barrier, with two remote listeners, scan refresh, in-place relay and full transport reconnect restoration, collision rejection, explicit removal, independent state inventories, HTTP responses, process identity, and no live forward-close warning."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 66,
|
||||
"summary": "The identical stale-snapshot and headed lifecycle passed through forced system OpenSSH, with a wrapper marker proving the -L forward process executed; in-place relay and full transport reconnects restored both intents, no unexpected forward close was captured before assertions, and intentional removal completed."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 180,
|
||||
"scope": "focused renderer/main contracts plus two headed Docker SSH transport runs"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The deterministic contract and both real transport runs passed locally; CI and soak history are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The identical renderer unit and headed Electron oracles fail on current main because the stale empty hydration reply becomes a second and final Forwarded write, pass with bounded per-target per-stream pending-hydration state, fail again when only that fix is removed, and pass again after restoration. In the tightened headed revert, both transports first proved main inventory, persisted intent, HTTP, remote process identity, no live close warning, and renderer continuation, then failed only at the missing Forwarded-row assertion. Both passed after rebuilding the restored candidate."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The fix adds one effect-scoped map containing only unresolved target hydrations, one O(1) boolean write per relevant existing push event, and one O(1) authority/stream check per initial snapshot. Forwarded and Detected hydrate independently so a stalled stream does not block its peer or later targets; entries are removed after both settle. It adds no polling, timers, IPC calls, scans, subprocesses, or renderer subscriptions."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
|
||||
"Run the headed topology on Linux and Windows with their native system-SSH clients.",
|
||||
"Re-run the headed snapshot barrier after Electron major-version upgrades.",
|
||||
"Keep the independent Forwarded and Detected freshness assertions and both transport runs green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Headed live evidence is macOS-only; Linux and Windows system-SSH clients were not exercised.",
|
||||
"The headed race uses Electron's private invoke-handler registry to delay the real listPortForwards handler because Electron exposes no public handler-wrapping API.",
|
||||
"The live run reconnects an existing app session but does not restart the packaged application from disk.",
|
||||
"The live topology uses an SSH Git worktree; folder-workspace behavior is covered by target-scoped renderer reconciliation rather than a second headed topology."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if same-authority snapshots can overwrite newer pushes, reconnect silently loses persisted intent, one forward operation disturbs unrelated forwards, live HTTP diverges from renderer/main inventory, or either transport topology flakes without an identified product or harness fault."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5539,6 +5539,306 @@ describe('useIpcEvents agent status snapshot integration', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('does not let initial SSH port snapshots overwrite newer push events', async () => {
|
||||
const targetId = 'target-ports'
|
||||
const secondTargetId = 'target-ports-second'
|
||||
const rejectingTargetId = 'target-ports-rejecting'
|
||||
const partialTargetId = 'target-ports-partial'
|
||||
const connectedState = {
|
||||
targetId,
|
||||
status: 'connected' as const,
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
providerEpoch: 'epoch-ports',
|
||||
connectionGeneration: 3
|
||||
}
|
||||
const secondConnectedState = {
|
||||
...connectedState,
|
||||
targetId: secondTargetId,
|
||||
providerEpoch: 'epoch-ports-second'
|
||||
}
|
||||
const rejectingConnectedState = {
|
||||
...connectedState,
|
||||
targetId: rejectingTargetId,
|
||||
providerEpoch: 'epoch-ports-rejecting'
|
||||
}
|
||||
const partialConnectedState = {
|
||||
...connectedState,
|
||||
targetId: partialTargetId,
|
||||
providerEpoch: null,
|
||||
connectionGeneration: undefined
|
||||
}
|
||||
const reconciledPartialState = {
|
||||
...connectedState,
|
||||
targetId: partialTargetId,
|
||||
providerEpoch: 'epoch-ports-partial',
|
||||
connectionGeneration: 4
|
||||
}
|
||||
const liveForward = {
|
||||
id: 'forward-live',
|
||||
targetId,
|
||||
localPort: 17860,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort: 7860,
|
||||
status: 'active' as const
|
||||
}
|
||||
const secondForward = {
|
||||
...liveForward,
|
||||
id: 'forward-second',
|
||||
targetId: secondTargetId,
|
||||
localPort: 17861,
|
||||
remotePort: 7861
|
||||
}
|
||||
const rejectingTargetForward = {
|
||||
...liveForward,
|
||||
id: 'forward-rejecting-target',
|
||||
targetId: rejectingTargetId,
|
||||
localPort: 17862,
|
||||
remotePort: 7862
|
||||
}
|
||||
const partialTargetForward = {
|
||||
...liveForward,
|
||||
id: 'forward-partial-target',
|
||||
targetId: partialTargetId,
|
||||
localPort: 17863,
|
||||
remotePort: 7863
|
||||
}
|
||||
const detectedPort = {
|
||||
port: 7860,
|
||||
pid: 42,
|
||||
processName: 'python',
|
||||
command: 'python -m http.server 7860'
|
||||
}
|
||||
const secondDetectedPort = {
|
||||
...detectedPort,
|
||||
port: 7861,
|
||||
pid: 43,
|
||||
command: 'python -m http.server 7861'
|
||||
}
|
||||
let resolveForwards: (value: []) => void = () => {}
|
||||
let resolveDetected: (value: (typeof detectedPort)[]) => void = () => {}
|
||||
let resolveSecondForwards: (value: (typeof secondForward)[]) => void = () => {}
|
||||
let resolveSecondDetected: (value: []) => void = () => {}
|
||||
const forwardsSnapshot = new Promise<[]>((resolve) => {
|
||||
resolveForwards = resolve
|
||||
})
|
||||
const detectedSnapshot = new Promise<(typeof detectedPort)[]>((resolve) => {
|
||||
resolveDetected = resolve
|
||||
})
|
||||
const secondForwardsSnapshot = new Promise<(typeof secondForward)[]>((resolve) => {
|
||||
resolveSecondForwards = resolve
|
||||
})
|
||||
const secondDetectedSnapshot = new Promise<[]>((resolve) => {
|
||||
resolveSecondDetected = resolve
|
||||
})
|
||||
const listPortForwards = vi.fn(({ targetId: requestedTargetId }: { targetId: string }) => {
|
||||
if (requestedTargetId === rejectingTargetId) {
|
||||
return Promise.resolve([rejectingTargetForward])
|
||||
}
|
||||
if (requestedTargetId === partialTargetId) {
|
||||
return Promise.resolve([partialTargetForward])
|
||||
}
|
||||
return requestedTargetId === targetId ? forwardsSnapshot : secondForwardsSnapshot
|
||||
})
|
||||
const listDetectedPorts = vi.fn(({ targetId: requestedTargetId }: { targetId: string }) => {
|
||||
if (requestedTargetId === rejectingTargetId) {
|
||||
return Promise.reject(new Error('detected snapshot unavailable'))
|
||||
}
|
||||
if (requestedTargetId === partialTargetId) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
return requestedTargetId === targetId ? detectedSnapshot : secondDetectedSnapshot
|
||||
})
|
||||
let forwardListener:
|
||||
| ((data: { targetId: string; forwards: (typeof liveForward)[] }) => void)
|
||||
| undefined
|
||||
let detectedListener:
|
||||
| ((data: { targetId: string; ports: (typeof detectedPort)[] }) => void)
|
||||
| undefined
|
||||
const setPortForwards = vi.fn()
|
||||
const setDetectedPorts = vi.fn()
|
||||
const sshConnectionStates = new Map<
|
||||
string,
|
||||
| typeof connectedState
|
||||
| typeof secondConnectedState
|
||||
| typeof rejectingConnectedState
|
||||
| typeof partialConnectedState
|
||||
| typeof reconciledPartialState
|
||||
>()
|
||||
const storeState = buildStoreState({
|
||||
sshTargetLabels: new Map([
|
||||
[targetId, 'Ports Target'],
|
||||
[secondTargetId, 'Second Ports Target'],
|
||||
[rejectingTargetId, 'Rejecting Ports Target'],
|
||||
[partialTargetId, 'Partial Ports Target']
|
||||
]),
|
||||
sshConnectionStates,
|
||||
setSshConnectionState: (
|
||||
nextTargetId: string,
|
||||
state:
|
||||
| typeof connectedState
|
||||
| typeof secondConnectedState
|
||||
| typeof rejectingConnectedState
|
||||
| typeof partialConnectedState
|
||||
| typeof reconciledPartialState
|
||||
) => {
|
||||
sshConnectionStates.set(nextTargetId, state)
|
||||
},
|
||||
setPortForwards,
|
||||
setDetectedPorts,
|
||||
setSshTargetsMetadata: vi.fn(),
|
||||
setRemovedSshTargetLabels: vi.fn(),
|
||||
setRemoteWorkspaceSyncStatus: vi.fn(),
|
||||
fetchRuntimeEnvironmentRepos: vi.fn(async () => []),
|
||||
fetchWorktreeLineage: vi.fn(async () => undefined),
|
||||
clearRemoteDetectedAgents: vi.fn(),
|
||||
clearDirectSshTargetPtyBindings: vi.fn(),
|
||||
clearRemovedSshTargetState: vi.fn(),
|
||||
invalidateStaleDirectSshTargetPtyBindings: vi.fn(() => 0),
|
||||
retryDirectSshTargetPanes: vi.fn(() => 0)
|
||||
})
|
||||
const coordinator = {
|
||||
requestReconnect: vi.fn(async () => ({ status: 'complete' })),
|
||||
replaceAuthority: vi.fn(),
|
||||
prepareOnly: vi.fn(async () => ({ token: null })),
|
||||
correctUnboundTerminals: vi.fn(() => 0),
|
||||
finalizeHydratedTerminals: vi.fn(() => 0),
|
||||
invalidate: vi.fn(),
|
||||
stop: vi.fn()
|
||||
}
|
||||
let partialTargetStateCalls = 0
|
||||
|
||||
stubReactSyncEffect()
|
||||
stubAuxiliaryModules()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
vi.doMock('./direct-ssh-reconnect-rollout', () => ({
|
||||
isDirectSshReconnectCoordinatorRoutingEnabled: () => true
|
||||
}))
|
||||
vi.doMock('./direct-ssh-worktree-refresh-scheduler', () => ({
|
||||
createDirectSshWorktreeRefreshScheduler: () => ({
|
||||
stop: vi.fn(),
|
||||
disposeProvider: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.doMock('./direct-ssh-host-hydration', () => ({
|
||||
createDirectSshHostHydration: () => ({
|
||||
capturePreparationInput: vi.fn(),
|
||||
readHostScopedLineage: vi.fn(),
|
||||
isPreparationTokenCurrent: vi.fn(() => true),
|
||||
stop: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.doMock('./direct-ssh-reconnect-coordinator', () => ({
|
||||
createDirectSshReconnectCoordinator: () => coordinator
|
||||
}))
|
||||
vi.doMock('@/lib/direct-ssh-reconnect-product-telemetry', () => ({
|
||||
createDirectSshReconnectProductTelemetryAdapter: vi.fn()
|
||||
}))
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: () => () => {},
|
||||
ssh: {
|
||||
listTargets: () =>
|
||||
Promise.resolve([
|
||||
{ id: rejectingTargetId, label: 'Rejecting Ports Target' },
|
||||
{ id: partialTargetId, label: 'Partial Ports Target' },
|
||||
{ id: targetId, label: 'Ports Target' },
|
||||
{ id: secondTargetId, label: 'Second Ports Target' }
|
||||
]),
|
||||
listRemovedTargetLabels: () => Promise.resolve({}),
|
||||
getState: ({ targetId: requestedTargetId }: { targetId: string }) => {
|
||||
if (requestedTargetId === partialTargetId) {
|
||||
partialTargetStateCalls += 1
|
||||
return Promise.resolve(
|
||||
partialTargetStateCalls === 1 ? partialConnectedState : reconciledPartialState
|
||||
)
|
||||
}
|
||||
return Promise.resolve(
|
||||
requestedTargetId === rejectingTargetId
|
||||
? rejectingConnectedState
|
||||
: requestedTargetId === targetId
|
||||
? connectedState
|
||||
: secondConnectedState
|
||||
)
|
||||
},
|
||||
listPortForwards,
|
||||
listDetectedPorts,
|
||||
onPortForwardsChanged: (
|
||||
listener: (data: { targetId: string; forwards: (typeof liveForward)[] }) => void
|
||||
) => {
|
||||
forwardListener = listener
|
||||
return () => {}
|
||||
},
|
||||
onDetectedPortsChanged: (
|
||||
listener: (data: { targetId: string; ports: (typeof detectedPort)[] }) => void
|
||||
) => {
|
||||
detectedListener = listener
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(forwardListener).toBeTypeOf('function')
|
||||
expect(detectedListener).toBeTypeOf('function')
|
||||
expect(partialTargetStateCalls).toBe(2)
|
||||
expect(sshConnectionStates.get(partialTargetId)).toEqual(reconciledPartialState)
|
||||
expect(listPortForwards).toHaveBeenCalledTimes(4)
|
||||
expect(listDetectedPorts).toHaveBeenCalledTimes(4)
|
||||
expect(
|
||||
setPortForwards.mock.calls.filter(
|
||||
([requestedTargetId]) => requestedTargetId === rejectingTargetId
|
||||
)
|
||||
).toEqual([[rejectingTargetId, [rejectingTargetForward]]])
|
||||
expect(
|
||||
setPortForwards.mock.calls.filter(
|
||||
([requestedTargetId]) => requestedTargetId === partialTargetId
|
||||
)
|
||||
).toEqual([[partialTargetId, [partialTargetForward]]])
|
||||
})
|
||||
forwardListener?.({ targetId, forwards: [liveForward] })
|
||||
resolveForwards([])
|
||||
resolveDetected([detectedPort])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
setPortForwards.mock.calls.filter(([requestedTargetId]) => requestedTargetId === targetId)
|
||||
).toEqual([[targetId, [liveForward]]])
|
||||
expect(
|
||||
setDetectedPorts.mock.calls.filter(([requestedTargetId]) => requestedTargetId === targetId)
|
||||
).toEqual([[targetId, [detectedPort]]])
|
||||
expect(listPortForwards).toHaveBeenCalledTimes(4)
|
||||
expect(listDetectedPorts).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
forwardListener?.({ targetId, forwards: [liveForward] })
|
||||
detectedListener?.({ targetId: secondTargetId, ports: [secondDetectedPort] })
|
||||
resolveSecondForwards([secondForward])
|
||||
resolveSecondDetected([])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
setPortForwards.mock.calls.filter(
|
||||
([requestedTargetId]) => requestedTargetId === secondTargetId
|
||||
)
|
||||
).toEqual([[secondTargetId, [secondForward]]])
|
||||
expect(
|
||||
setDetectedPorts.mock.calls.filter(
|
||||
([requestedTargetId]) => requestedTargetId === secondTargetId
|
||||
)
|
||||
).toEqual([[secondTargetId, [secondDetectedPort]]])
|
||||
})
|
||||
})
|
||||
|
||||
it('caps pending mobile state events while startup hydration is unresolved', async () => {
|
||||
const setFitOverride = vi.fn()
|
||||
const hydrateOverrides = vi.fn()
|
||||
|
||||
@@ -2631,6 +2631,37 @@ export function useIpcEvents(): void {
|
||||
}
|
||||
|
||||
const sshStateWatermarkByTargetId = new Map<string, number>()
|
||||
const pendingPortHydrationByTargetId = new Map<
|
||||
string,
|
||||
{ receivedForwardPush: boolean; receivedDetectedPush: boolean }
|
||||
>()
|
||||
const hydrateSshPorts = (targetId: string, authority: DirectSshAuthority): void => {
|
||||
const pendingPortHydration = {
|
||||
receivedForwardPush: false,
|
||||
receivedDetectedPush: false
|
||||
}
|
||||
pendingPortHydrationByTargetId.set(targetId, pendingPortHydration)
|
||||
const isHydrationAuthorityCurrent = (): boolean =>
|
||||
!directSshEffectStopped &&
|
||||
directSshAuthoritiesEqual(currentDirectSshAuthority(targetId), authority)
|
||||
const forwardHydration = window.api.ssh.listPortForwards({ targetId }).then((forwards) => {
|
||||
// Why: if the session disconnected while awaiting the snapshot, applying it would resurrect a dead session's ports.
|
||||
if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedForwardPush) {
|
||||
useAppStore.getState().setPortForwards(targetId, forwards)
|
||||
}
|
||||
})
|
||||
const detectedHydration = window.api.ssh.listDetectedPorts({ targetId }).then((detected) => {
|
||||
if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedDetectedPush) {
|
||||
useAppStore.getState().setDetectedPorts(targetId, detected)
|
||||
}
|
||||
})
|
||||
// Why: one failed or stalled port stream must not block the other stream or later targets.
|
||||
void Promise.allSettled([forwardHydration, detectedHydration]).then(() => {
|
||||
if (pendingPortHydrationByTargetId.get(targetId) === pendingPortHydration) {
|
||||
pendingPortHydrationByTargetId.delete(targetId)
|
||||
}
|
||||
})
|
||||
}
|
||||
let applySshConnectionStateChange!: (
|
||||
targetId: string,
|
||||
state: SshConnectionState,
|
||||
@@ -2668,23 +2699,6 @@ export function useIpcEvents(): void {
|
||||
state as SshConnectionState,
|
||||
'initial-hydration'
|
||||
)
|
||||
// Why: ports arrive only via push events; on reattach to a live session fetch snapshots or the Ports panel shows empty.
|
||||
if ((state as SshConnectionState).status === 'connected') {
|
||||
const authority = currentDirectSshAuthority(target.id)
|
||||
const [forwards, detected] = await Promise.all([
|
||||
window.api.ssh.listPortForwards({ targetId: target.id }),
|
||||
window.api.ssh.listDetectedPorts({ targetId: target.id })
|
||||
])
|
||||
// Why: if the session disconnected while awaiting the snapshot, applying it would resurrect a dead session's ports.
|
||||
if (
|
||||
!directSshEffectStopped &&
|
||||
authority &&
|
||||
directSshAuthoritiesEqual(currentDirectSshAuthority(target.id), authority)
|
||||
) {
|
||||
useAppStore.getState().setPortForwards(target.id, forwards)
|
||||
useAppStore.getState().setDetectedPorts(target.id, detected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -2706,12 +2720,20 @@ export function useIpcEvents(): void {
|
||||
|
||||
unsubs.push(
|
||||
window.api.ssh.onPortForwardsChanged(({ targetId, forwards }) => {
|
||||
const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId)
|
||||
if (pendingPortHydration) {
|
||||
pendingPortHydration.receivedForwardPush = true
|
||||
}
|
||||
useAppStore.getState().setPortForwards(targetId, forwards)
|
||||
})
|
||||
)
|
||||
|
||||
unsubs.push(
|
||||
window.api.ssh.onDetectedPortsChanged(({ targetId, ports }) => {
|
||||
const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId)
|
||||
if (pendingPortHydration) {
|
||||
pendingPortHydration.receivedDetectedPush = true
|
||||
}
|
||||
useAppStore.getState().setDetectedPorts(targetId, ports)
|
||||
})
|
||||
)
|
||||
@@ -2736,7 +2758,7 @@ export function useIpcEvents(): void {
|
||||
latest?.targetId !== targetId ||
|
||||
!latest?.providerEpoch ||
|
||||
latest.connectionGeneration === undefined ||
|
||||
sshStateWatermarkByTargetId.get(targetId) !== watermark
|
||||
(sshStateWatermarkByTargetId.get(targetId) ?? 0) !== watermark
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -2840,6 +2862,10 @@ export function useIpcEvents(): void {
|
||||
},
|
||||
{ authority, previousAuthority, origin }
|
||||
)
|
||||
// Why: initial connected state can be partial; hydrate only after reconciliation yields a complete authority.
|
||||
if (origin === 'initial-hydration') {
|
||||
hydrateSshPorts(targetId, authority)
|
||||
}
|
||||
}
|
||||
|
||||
let sshTargetStateEventId = 0
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { request } from 'node:http'
|
||||
|
||||
import { expect, type ElectronApplication, type Page } from '@stablyai/playwright-test'
|
||||
import {
|
||||
execDockerSshRelayTargetCommand,
|
||||
shellQuote,
|
||||
type DockerSshRelayTarget
|
||||
} from './docker-ssh-relay-target'
|
||||
|
||||
export type PortForwardEvidence = {
|
||||
events: { targetId: string; forwards: { localPort: number; remotePort: number }[] }[]
|
||||
rendererForwards: { localPort: number; remotePort: number }[]
|
||||
managerForwards: { localPort: number; remotePort: number }[]
|
||||
persistedForwards: { localPort: number; remotePort: number }[]
|
||||
}
|
||||
|
||||
export function requestForward(localPort: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request(
|
||||
{ host: '127.0.0.1', port: localPort, path: '/', method: 'GET', timeout: 2_000 },
|
||||
(response) => {
|
||||
let body = ''
|
||||
response.setEncoding('utf8')
|
||||
response.on('data', (chunk) => {
|
||||
body += chunk
|
||||
})
|
||||
response.on('end', () => resolve(body))
|
||||
}
|
||||
)
|
||||
req.once('error', reject)
|
||||
req.once('timeout', () => req.destroy(new Error('Forwarded HTTP request timed out')))
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
export function startRemoteHttpListener(
|
||||
target: DockerSshRelayTarget,
|
||||
port: number,
|
||||
marker: string
|
||||
): number {
|
||||
const script = [
|
||||
"const http = require('node:http')",
|
||||
`const marker = ${JSON.stringify(marker)}`,
|
||||
"const server = http.createServer((_request, response) => response.end(marker + '\\n'))",
|
||||
`server.listen(${port}, '127.0.0.1')`
|
||||
].join(';')
|
||||
execDockerSshRelayTargetCommand(
|
||||
target,
|
||||
[
|
||||
`nohup node -e ${shellQuote(script)} >/tmp/orca-http-${port}.log 2>&1 < /dev/null &`,
|
||||
`echo $! >/tmp/orca-http-${port}.pid`
|
||||
].join(' ')
|
||||
)
|
||||
return Number(execDockerSshRelayTargetCommand(target, `cat /tmp/orca-http-${port}.pid`))
|
||||
}
|
||||
|
||||
export function readRemoteListenerIdentity(
|
||||
target: DockerSshRelayTarget,
|
||||
port: number
|
||||
): { pid: number; executable: string; command: string } {
|
||||
const pid = Number(execDockerSshRelayTargetCommand(target, `cat /tmp/orca-http-${port}.pid`))
|
||||
return {
|
||||
pid,
|
||||
executable: execDockerSshRelayTargetCommand(target, `readlink /proc/${pid}/exe`),
|
||||
command: execDockerSshRelayTargetCommand(target, `tr '\\000' ' ' </proc/${pid}/cmdline`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function installLifecycleWarningCapture(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate(() => {
|
||||
const scope = globalThis as typeof globalThis & {
|
||||
__sshPortForwardWarnings?: string[]
|
||||
__sshPortForwardOriginalWarn?: typeof console.warn
|
||||
}
|
||||
scope.__sshPortForwardWarnings = []
|
||||
scope.__sshPortForwardOriginalWarn = console.warn
|
||||
console.warn = (...args: unknown[]) => {
|
||||
const message = args.map(String).join(' ')
|
||||
if (message.includes('[ssh')) {
|
||||
scope.__sshPortForwardWarnings?.push(message)
|
||||
}
|
||||
scope.__sshPortForwardOriginalWarn?.(...args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function readLifecycleWarnings(electronApp: ElectronApplication): Promise<string[]> {
|
||||
return electronApp.evaluate(() => {
|
||||
const scope = globalThis as typeof globalThis & {
|
||||
__sshPortForwardWarnings?: string[]
|
||||
}
|
||||
return scope.__sshPortForwardWarnings ?? []
|
||||
})
|
||||
}
|
||||
|
||||
export async function restoreLifecycleWarningCapture(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate(() => {
|
||||
const scope = globalThis as typeof globalThis & {
|
||||
__sshPortForwardWarnings?: string[]
|
||||
__sshPortForwardOriginalWarn?: typeof console.warn
|
||||
}
|
||||
if (scope.__sshPortForwardOriginalWarn) {
|
||||
console.warn = scope.__sshPortForwardOriginalWarn
|
||||
}
|
||||
delete scope.__sshPortForwardWarnings
|
||||
delete scope.__sshPortForwardOriginalWarn
|
||||
})
|
||||
}
|
||||
|
||||
export async function installRendererForwardCapture(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const scope = window as typeof window & {
|
||||
__sshPortForwardEvents?: unknown[]
|
||||
__sshPortForwardUnsubscribe?: () => void
|
||||
}
|
||||
scope.__sshPortForwardEvents = []
|
||||
scope.__sshPortForwardUnsubscribe?.()
|
||||
scope.__sshPortForwardUnsubscribe = window.api.ssh.onPortForwardsChanged((event) => {
|
||||
scope.__sshPortForwardEvents?.push(event)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function readPortForwardEvidence(
|
||||
page: Page,
|
||||
targetId: string
|
||||
): Promise<PortForwardEvidence> {
|
||||
return page.evaluate(
|
||||
async ({ targetId }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
const target = (await window.api.ssh.listTargets()).find((entry) => entry.id === targetId)
|
||||
const scope = window as typeof window & {
|
||||
__sshPortForwardEvents?: PortForwardEvidence['events']
|
||||
}
|
||||
return {
|
||||
events: scope.__sshPortForwardEvents ?? [],
|
||||
rendererForwards: store.getState().portForwardsByConnection[targetId] ?? [],
|
||||
managerForwards: await window.api.ssh.listPortForwards({ targetId }),
|
||||
persistedForwards: target?.portForwards ?? []
|
||||
}
|
||||
},
|
||||
{ targetId }
|
||||
)
|
||||
}
|
||||
|
||||
export async function openPortsPanel(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setRightSidebarTab('ports')
|
||||
state?.setRightSidebarOpen(true)
|
||||
})
|
||||
await expect(page.getByText('Ports', { exact: true }).last()).toBeVisible()
|
||||
}
|
||||
|
||||
export async function forwardPortFromPanel(
|
||||
page: Page,
|
||||
localPort: number,
|
||||
remotePort: number
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Add', exact: true }).last().click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Forward a Port' })
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByLabel('Remote Port').fill(String(remotePort))
|
||||
await dialog.getByLabel('Local Port').fill(String(localPort))
|
||||
await dialog.getByRole('button', { name: 'Forward', exact: true }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
}
|
||||
|
||||
export async function addPortForward(
|
||||
page: Page,
|
||||
args: {
|
||||
targetId: string
|
||||
localPort: number
|
||||
remotePort: number
|
||||
label: string
|
||||
}
|
||||
): Promise<{ id: string }> {
|
||||
return page.evaluate(
|
||||
({ targetId, localPort, remotePort, label }) =>
|
||||
window.api.ssh.addPortForward({
|
||||
targetId,
|
||||
localPort,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort,
|
||||
label
|
||||
}),
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
export async function expectForwardEvidence(
|
||||
page: Page,
|
||||
targetId: string,
|
||||
expected: { localPort: number; remotePort: number }[]
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const evidence = await readPortForwardEvidence(page, targetId)
|
||||
return {
|
||||
renderer: evidence.rendererForwards.map(({ localPort, remotePort }) => ({
|
||||
localPort,
|
||||
remotePort
|
||||
})),
|
||||
manager: evidence.managerForwards.map(({ localPort, remotePort }) => ({
|
||||
localPort,
|
||||
remotePort
|
||||
})),
|
||||
persisted: evidence.persistedForwards.map(({ localPort, remotePort }) => ({
|
||||
localPort,
|
||||
remotePort
|
||||
}))
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000, message: 'renderer, manager, and persisted forward state did not agree' }
|
||||
)
|
||||
.toEqual({ renderer: expected, manager: expected, persisted: expected })
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { createServer } from 'node:net'
|
||||
|
||||
import type { ElectronApplication } from '@stablyai/playwright-test'
|
||||
|
||||
type InvokeHandler = (event: unknown, args?: { targetId?: string }) => unknown
|
||||
|
||||
type SnapshotBarrierState = {
|
||||
targetId: string
|
||||
captureClaimed: boolean
|
||||
captured: boolean
|
||||
released: boolean
|
||||
release: () => void
|
||||
originalHandler: InvokeHandler
|
||||
handlerReturned: Promise<void>
|
||||
markHandlerReturned: () => void
|
||||
}
|
||||
|
||||
export type ReservedLocalPort = {
|
||||
port: number
|
||||
release: () => Promise<void>
|
||||
}
|
||||
|
||||
export async function reserveLocalPort(): Promise<ReservedLocalPort> {
|
||||
const server = createServer()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close()
|
||||
throw new Error('Unable to reserve a local port')
|
||||
}
|
||||
let released = false
|
||||
return {
|
||||
port: address.port,
|
||||
release: async () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSshPortForwardSnapshotBarrier(
|
||||
app: ElectronApplication,
|
||||
targetId: string
|
||||
): Promise<void> {
|
||||
await app.evaluate(({ ipcMain }, targetId) => {
|
||||
const scope = globalThis as typeof globalThis & {
|
||||
__sshPortForwardSnapshotBarrier?: SnapshotBarrierState
|
||||
}
|
||||
const handlers = (
|
||||
ipcMain as unknown as {
|
||||
_invokeHandlers?: Map<string, InvokeHandler>
|
||||
}
|
||||
)._invokeHandlers
|
||||
const originalHandler = handlers?.get('ssh:listPortForwards')
|
||||
if (!handlers || !originalHandler) {
|
||||
throw new Error('ssh:listPortForwards handler is unavailable')
|
||||
}
|
||||
if (scope.__sshPortForwardSnapshotBarrier) {
|
||||
throw new Error('SSH port-forward snapshot barrier is already installed')
|
||||
}
|
||||
let release!: () => void
|
||||
const barrier = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
let markHandlerReturned!: () => void
|
||||
const handlerReturned = new Promise<void>((resolve) => {
|
||||
markHandlerReturned = resolve
|
||||
})
|
||||
const state: SnapshotBarrierState = {
|
||||
targetId,
|
||||
captureClaimed: false,
|
||||
captured: false,
|
||||
released: false,
|
||||
release,
|
||||
originalHandler,
|
||||
handlerReturned,
|
||||
markHandlerReturned
|
||||
}
|
||||
scope.__sshPortForwardSnapshotBarrier = state
|
||||
handlers.set('ssh:listPortForwards', async (event, args) => {
|
||||
if (state.captureClaimed || args?.targetId !== state.targetId) {
|
||||
return state.originalHandler(event, args)
|
||||
}
|
||||
state.captureClaimed = true
|
||||
const snapshot = await state.originalHandler(event, args)
|
||||
state.captured = true
|
||||
await barrier
|
||||
state.markHandlerReturned()
|
||||
return snapshot
|
||||
})
|
||||
}, targetId)
|
||||
}
|
||||
|
||||
export async function readSshPortForwardSnapshotBarrier(
|
||||
app: ElectronApplication
|
||||
): Promise<{ captured: boolean; released: boolean }> {
|
||||
return app.evaluate(() => {
|
||||
const state = (
|
||||
globalThis as typeof globalThis & {
|
||||
__sshPortForwardSnapshotBarrier?: SnapshotBarrierState
|
||||
}
|
||||
).__sshPortForwardSnapshotBarrier
|
||||
return {
|
||||
captured: state?.captured ?? false,
|
||||
released: state?.released ?? false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function releaseSshPortForwardSnapshotBarrier(
|
||||
app: ElectronApplication
|
||||
): Promise<void> {
|
||||
await app.evaluate(async () => {
|
||||
const state = (
|
||||
globalThis as typeof globalThis & {
|
||||
__sshPortForwardSnapshotBarrier?: SnapshotBarrierState
|
||||
}
|
||||
).__sshPortForwardSnapshotBarrier
|
||||
if (state && !state.released) {
|
||||
state.released = true
|
||||
state.release()
|
||||
}
|
||||
await state?.handlerReturned
|
||||
})
|
||||
}
|
||||
|
||||
export async function restoreSshPortForwardSnapshotHandler(
|
||||
app: ElectronApplication
|
||||
): Promise<void> {
|
||||
await app.evaluate(({ ipcMain }) => {
|
||||
const scope = globalThis as typeof globalThis & {
|
||||
__sshPortForwardSnapshotBarrier?: SnapshotBarrierState
|
||||
}
|
||||
const state = scope.__sshPortForwardSnapshotBarrier
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
if (!state.released) {
|
||||
state.released = true
|
||||
state.release()
|
||||
}
|
||||
const handlers = (
|
||||
ipcMain as unknown as {
|
||||
_invokeHandlers?: Map<string, InvokeHandler>
|
||||
}
|
||||
)._invokeHandlers
|
||||
handlers?.set('ssh:listPortForwards', state.originalHandler)
|
||||
delete scope.__sshPortForwardSnapshotBarrier
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ElectronApplication } from '@stablyai/playwright-test'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
installSshPortForwardSnapshotBarrier,
|
||||
readSshPortForwardSnapshotBarrier,
|
||||
releaseSshPortForwardSnapshotBarrier,
|
||||
restoreSshPortForwardSnapshotHandler
|
||||
} from './ssh-port-forward-snapshot-barrier'
|
||||
|
||||
type InvokeHandler = (event: unknown, args?: { targetId?: string }) => unknown
|
||||
|
||||
describe('SSH port-forward snapshot barrier', () => {
|
||||
it('holds only the first matching request while its snapshot is unresolved', async () => {
|
||||
const handlers = new Map<string, InvokeHandler>()
|
||||
let resolveFirstSnapshot: (value: string[]) => void = () => {}
|
||||
const firstSnapshot = new Promise<string[]>((resolve) => {
|
||||
resolveFirstSnapshot = resolve
|
||||
})
|
||||
let callCount = 0
|
||||
const originalHandler = vi.fn(() => {
|
||||
callCount += 1
|
||||
return callCount === 1 ? firstSnapshot : Promise.resolve(['later-snapshot'])
|
||||
})
|
||||
handlers.set('ssh:listPortForwards', originalHandler)
|
||||
const app = {
|
||||
evaluate: (
|
||||
callback: (electron: unknown, arg?: unknown) => unknown,
|
||||
arg?: unknown
|
||||
): Promise<unknown> =>
|
||||
Promise.resolve(callback({ ipcMain: { _invokeHandlers: handlers } }, arg))
|
||||
} as unknown as ElectronApplication
|
||||
|
||||
let heldRequestStarted = false
|
||||
await installSshPortForwardSnapshotBarrier(app, 'target-1')
|
||||
try {
|
||||
const wrappedHandler = handlers.get('ssh:listPortForwards')
|
||||
expect(wrappedHandler).toBeTypeOf('function')
|
||||
if (!wrappedHandler) {
|
||||
throw new Error('Wrapped handler unavailable')
|
||||
}
|
||||
|
||||
heldRequestStarted = true
|
||||
const firstRequest = Promise.resolve(wrappedHandler({}, { targetId: 'target-1' }))
|
||||
await vi.waitFor(() => expect(originalHandler).toHaveBeenCalledOnce())
|
||||
const laterRequest = Promise.resolve(wrappedHandler({}, { targetId: 'target-1' }))
|
||||
await expect(laterRequest).resolves.toEqual(['later-snapshot'])
|
||||
expect(await readSshPortForwardSnapshotBarrier(app)).toEqual({
|
||||
captured: false,
|
||||
released: false
|
||||
})
|
||||
|
||||
resolveFirstSnapshot(['held-snapshot'])
|
||||
await vi.waitFor(async () => {
|
||||
expect(await readSshPortForwardSnapshotBarrier(app)).toEqual({
|
||||
captured: true,
|
||||
released: false
|
||||
})
|
||||
})
|
||||
await releaseSshPortForwardSnapshotBarrier(app)
|
||||
await expect(firstRequest).resolves.toEqual(['held-snapshot'])
|
||||
} finally {
|
||||
resolveFirstSnapshot(['cleanup-snapshot'])
|
||||
if (heldRequestStarted) {
|
||||
await releaseSshPortForwardSnapshotBarrier(app)
|
||||
}
|
||||
await restoreSshPortForwardSnapshotHandler(app)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { expect, type ElectronApplication, type Page } from '@stablyai/playwright-test'
|
||||
import {
|
||||
execDockerSshRelayTargetCommand,
|
||||
shellQuote,
|
||||
type DockerSshRelayTarget
|
||||
} from './docker-ssh-relay-target'
|
||||
|
||||
type CapturedSshState = {
|
||||
status: string
|
||||
providerEpoch?: string
|
||||
connectionGeneration?: number
|
||||
}
|
||||
|
||||
export async function trustDockerSshHost(
|
||||
electronApp: ElectronApplication,
|
||||
target: DockerSshRelayTarget
|
||||
): Promise<string> {
|
||||
const sshDir = join(target.tempDir, '.ssh')
|
||||
mkdirSync(sshDir, { recursive: true })
|
||||
const knownHostsPath = join(sshDir, 'known_hosts')
|
||||
const hostKeys = execFileSync('ssh-keyscan', ['-p', String(target.port), '127.0.0.1'], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
writeFileSync(knownHostsPath, hostKeys)
|
||||
const invocationLogPath = join(target.tempDir, 'system-ssh-invocations')
|
||||
const wrapperPath = join(target.tempDir, 'verified-system-ssh')
|
||||
writeFileSync(
|
||||
wrapperPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'kind=transport',
|
||||
'for arg in "$@"; do',
|
||||
' if [ "$arg" = "-L" ]; then kind=forward; fi',
|
||||
'done',
|
||||
`printf '%s\\n' "$kind" >> ${shellQuote(invocationLogPath)}`,
|
||||
`exec /usr/bin/ssh -o UserKnownHostsFile=${shellQuote(knownHostsPath)} -o StrictHostKeyChecking=yes "$@"`
|
||||
].join('\n')
|
||||
)
|
||||
chmodSync(wrapperPath, 0o755)
|
||||
await electronApp.evaluate((_electron, path) => {
|
||||
process.env.ORCA_SYSTEM_SSH_PATH = path
|
||||
}, wrapperPath)
|
||||
return invocationLogPath
|
||||
}
|
||||
|
||||
export function readSystemSshInvocationKinds(invocationLogPath: string): string[] {
|
||||
if (!existsSync(invocationLogPath)) {
|
||||
return []
|
||||
}
|
||||
return readFileSync(invocationLogPath, 'utf8').split(/\r?\n/).filter(Boolean)
|
||||
}
|
||||
|
||||
export function terminateDockerSshRelayConnectChannel(target: DockerSshRelayTarget): number {
|
||||
const output = execDockerSshRelayTargetCommand(
|
||||
target,
|
||||
`
|
||||
count=0
|
||||
for proc in /proc/[0-9]*; do
|
||||
[ -r "$proc/cmdline" ] || continue
|
||||
argv=()
|
||||
mapfile -d '' -t argv < "$proc/cmdline" 2>/dev/null || continue
|
||||
[ "\${argv[1]##*/}" = relay.js ] || continue
|
||||
mode=
|
||||
for arg in "\${argv[@]:2}"; do
|
||||
if [ "$arg" = --connect ]; then mode=connect; fi
|
||||
if [ "$arg" = --detached ]; then mode=detached; fi
|
||||
done
|
||||
if [ "$mode" = connect ]; then
|
||||
kill -TERM "\${proc##*/}"
|
||||
count=$((count + 1))
|
||||
fi
|
||||
done
|
||||
printf '%s' "$count"
|
||||
`
|
||||
)
|
||||
return Number(output)
|
||||
}
|
||||
|
||||
export async function installSshStateCapture(page: Page, targetId: string): Promise<void> {
|
||||
await page.evaluate((targetId) => {
|
||||
const scope = window as typeof window & {
|
||||
__sshLifecycleStates?: CapturedSshState[]
|
||||
__sshLifecycleStateUnsubscribe?: () => void
|
||||
}
|
||||
scope.__sshLifecycleStateUnsubscribe?.()
|
||||
scope.__sshLifecycleStates = []
|
||||
scope.__sshLifecycleStateUnsubscribe = window.api.ssh.onStateChanged((event) => {
|
||||
if (event.targetId === targetId) {
|
||||
scope.__sshLifecycleStates?.push(event.state)
|
||||
}
|
||||
})
|
||||
}, targetId)
|
||||
}
|
||||
|
||||
export async function readSshStateCapture(page: Page): Promise<CapturedSshState[]> {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as typeof window & {
|
||||
__sshLifecycleStates?: CapturedSshState[]
|
||||
}
|
||||
).__sshLifecycleStates ?? []
|
||||
)
|
||||
}
|
||||
|
||||
export async function forceDockerSshRelayChannelReconnect(
|
||||
page: Page,
|
||||
target: DockerSshRelayTarget,
|
||||
targetId: string
|
||||
): Promise<void> {
|
||||
await installSshStateCapture(page, targetId)
|
||||
const authority = await page.evaluate(
|
||||
(targetId) => window.__store?.getState().sshConnectionStates.get(targetId),
|
||||
targetId
|
||||
)
|
||||
expect(authority).toMatchObject({
|
||||
status: 'connected',
|
||||
providerEpoch: expect.any(String),
|
||||
connectionGeneration: expect.any(Number)
|
||||
})
|
||||
expect(terminateDockerSshRelayConnectChannel(target)).toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const states = await readSshStateCapture(page)
|
||||
const current = await page.evaluate(
|
||||
(targetId) => window.__store?.getState().sshConnectionStates.get(targetId),
|
||||
targetId
|
||||
)
|
||||
return (
|
||||
states.some((state) => state.status === 'reconnecting') &&
|
||||
states.some((state) => state.status === 'connected') &&
|
||||
current?.status === 'connected' &&
|
||||
(current.providerEpoch !== authority?.providerEpoch ||
|
||||
current.connectionGeneration !== authority?.connectionGeneration)
|
||||
)
|
||||
},
|
||||
{ timeout: 30_000, message: 'in-place relay channel did not reconnect' }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { createServer } from 'node:net'
|
||||
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
cleanupDockerSshRelayTarget,
|
||||
startDockerSshRelayTarget,
|
||||
type DockerSshRelayTarget
|
||||
} from './helpers/docker-ssh-relay-target'
|
||||
import {
|
||||
connectDockerSshRelayTarget,
|
||||
reconnectDockerSshRelayTarget
|
||||
} from './helpers/docker-ssh-relay-connection'
|
||||
import {
|
||||
installSshPortForwardSnapshotBarrier,
|
||||
readSshPortForwardSnapshotBarrier,
|
||||
releaseSshPortForwardSnapshotBarrier,
|
||||
reserveLocalPort,
|
||||
restoreSshPortForwardSnapshotHandler
|
||||
} from './helpers/ssh-port-forward-snapshot-barrier'
|
||||
import {
|
||||
addPortForward,
|
||||
expectForwardEvidence,
|
||||
forwardPortFromPanel,
|
||||
installLifecycleWarningCapture,
|
||||
installRendererForwardCapture,
|
||||
openPortsPanel,
|
||||
readLifecycleWarnings,
|
||||
readPortForwardEvidence,
|
||||
readRemoteListenerIdentity,
|
||||
requestForward,
|
||||
restoreLifecycleWarningCapture,
|
||||
startRemoteHttpListener
|
||||
} from './helpers/ssh-port-forward-lifecycle-evidence'
|
||||
import {
|
||||
forceDockerSshRelayChannelReconnect,
|
||||
readSshStateCapture,
|
||||
readSystemSshInvocationKinds,
|
||||
trustDockerSshHost
|
||||
} from './helpers/ssh-port-forward-transport-evidence'
|
||||
|
||||
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
|
||||
const FORCE_SYSTEM_SSH = process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1'
|
||||
const REMOTE_PORT = 7860
|
||||
const REFRESH_BARRIER_PORT = 7861
|
||||
const SCAN_REFRESH_PORT = 7862
|
||||
|
||||
test.describe('Docker SSH port-forward lifecycle', () => {
|
||||
test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.')
|
||||
test.skip(process.platform === 'win32', 'Docker SSH lifecycle uses POSIX process inspection.')
|
||||
|
||||
test('keeps a user-forwarded listener live across scan refresh @headful', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.slow()
|
||||
let target: DockerSshRelayTarget | null = null
|
||||
const localPortReservation = await reserveLocalPort()
|
||||
const unrelatedLocalPortReservation = await reserveLocalPort()
|
||||
const localPort = localPortReservation.port
|
||||
const unrelatedLocalPort = unrelatedLocalPortReservation.port
|
||||
const marker = `ORCA_FORWARD_${Date.now()}`
|
||||
const unrelatedMarker = `${marker}_UNRELATED`
|
||||
try {
|
||||
target = startDockerSshRelayTarget(testInfo)
|
||||
const systemSshInvocationLogPath = await trustDockerSshHost(electronApp, target)
|
||||
await installLifecycleWarningCapture(electronApp)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const remote = await connectDockerSshRelayTarget(orcaPage, target)
|
||||
const remotePid = startRemoteHttpListener(target, REMOTE_PORT, marker)
|
||||
const unrelatedRemotePid = startRemoteHttpListener(
|
||||
target,
|
||||
REFRESH_BARRIER_PORT,
|
||||
unrelatedMarker
|
||||
)
|
||||
await openPortsPanel(orcaPage)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
({ targetId, port }) =>
|
||||
window.api.ssh
|
||||
.listDetectedPorts({ targetId })
|
||||
.then((ports) => ports.find((entry) => entry.port === port)?.pid ?? null),
|
||||
{ targetId: remote.targetId, port: REMOTE_PORT }
|
||||
),
|
||||
{ timeout: 45_000, message: 'remote HTTP listener was not detected' }
|
||||
)
|
||||
.toBe(remotePid)
|
||||
await expect(orcaPage.getByText(`:${REMOTE_PORT}`, { exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
({ targetId, port }) =>
|
||||
window.api.ssh
|
||||
.listDetectedPorts({ targetId })
|
||||
.then((ports) => ports.some((entry) => entry.port === port)),
|
||||
{ targetId: remote.targetId, port: REFRESH_BARRIER_PORT }
|
||||
),
|
||||
{ timeout: 45_000, message: 'scan-refresh barrier listener was not detected' }
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(orcaPage.getByText(`:${REFRESH_BARRIER_PORT}`, { exact: true })).toBeVisible()
|
||||
|
||||
await installSshPortForwardSnapshotBarrier(electronApp, remote.targetId)
|
||||
await orcaPage.evaluate(() => window.dispatchEvent(new Event('beforeunload')))
|
||||
await orcaPage.reload()
|
||||
await waitForSessionReady(orcaPage, 60_000)
|
||||
await expect
|
||||
.poll(() => waitForActiveWorktree(orcaPage), { timeout: 60_000 })
|
||||
.toBe(remote.worktreeId)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
(targetId) => window.__store?.getState().sshConnectionStates.get(targetId)?.status,
|
||||
remote.targetId
|
||||
),
|
||||
{ timeout: 60_000, message: 'renderer did not restore the connected SSH target' }
|
||||
)
|
||||
.toBe('connected')
|
||||
await expect
|
||||
.poll(() => readSshPortForwardSnapshotBarrier(electronApp), {
|
||||
timeout: 30_000,
|
||||
message: 'renderer hydration did not capture an empty Forwarded snapshot'
|
||||
})
|
||||
.toEqual({ captured: true, released: false })
|
||||
|
||||
await installRendererForwardCapture(orcaPage)
|
||||
await openPortsPanel(orcaPage)
|
||||
await localPortReservation.release()
|
||||
await forwardPortFromPanel(orcaPage, localPort, REMOTE_PORT)
|
||||
await expect(orcaPage.getByText('Forwarded', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true })
|
||||
).toBeVisible()
|
||||
await expect.poll(() => requestForward(localPort)).toContain(marker)
|
||||
await expectForwardEvidence(orcaPage, remote.targetId, [
|
||||
{ localPort, remotePort: REMOTE_PORT }
|
||||
])
|
||||
if (FORCE_SYSTEM_SSH) {
|
||||
await expect
|
||||
.poll(() => readSystemSshInvocationKinds(systemSshInvocationLogPath))
|
||||
.toContain('forward')
|
||||
} else {
|
||||
expect(readSystemSshInvocationKinds(systemSshInvocationLogPath)).not.toContain('forward')
|
||||
}
|
||||
|
||||
await releaseSshPortForwardSnapshotBarrier(electronApp)
|
||||
const postHydrationRoundTripForwards = await orcaPage.evaluate(
|
||||
(targetId) => window.api.ssh.listPortForwards({ targetId }),
|
||||
remote.targetId
|
||||
)
|
||||
expect(postHydrationRoundTripForwards).toContainEqual(
|
||||
expect.objectContaining({ localPort, remotePort: REMOTE_PORT })
|
||||
)
|
||||
await expect(orcaPage.getByText(`:${REFRESH_BARRIER_PORT}`, { exact: true })).toBeVisible()
|
||||
const staleSnapshotEvidence = await readPortForwardEvidence(orcaPage, remote.targetId)
|
||||
const staleSnapshotIdentity = readRemoteListenerIdentity(target, REMOTE_PORT)
|
||||
const staleSnapshotWarnings = await readLifecycleWarnings(electronApp)
|
||||
expect(staleSnapshotEvidence.managerForwards).toContainEqual(
|
||||
expect.objectContaining({ localPort, remotePort: REMOTE_PORT })
|
||||
)
|
||||
expect(staleSnapshotEvidence.persistedForwards).toContainEqual(
|
||||
expect.objectContaining({ localPort, remotePort: REMOTE_PORT })
|
||||
)
|
||||
await expect(requestForward(localPort)).resolves.toContain(marker)
|
||||
expect(staleSnapshotIdentity).toMatchObject({
|
||||
pid: remotePid,
|
||||
executable: expect.stringContaining('/node'),
|
||||
command: expect.stringContaining(String(REMOTE_PORT))
|
||||
})
|
||||
expect(staleSnapshotWarnings.filter((message) => message.includes('Port forward'))).toEqual(
|
||||
[]
|
||||
)
|
||||
await expect(
|
||||
orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true })
|
||||
).toBeVisible()
|
||||
await expectForwardEvidence(orcaPage, remote.targetId, [
|
||||
{ localPort, remotePort: REMOTE_PORT }
|
||||
])
|
||||
await restoreSshPortForwardSnapshotHandler(electronApp)
|
||||
|
||||
startRemoteHttpListener(target, SCAN_REFRESH_PORT, `${marker}_SCAN_REFRESH`)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
({ targetId, port }) =>
|
||||
window.api.ssh
|
||||
.listDetectedPorts({ targetId })
|
||||
.then((ports) => ports.some((entry) => entry.port === port)),
|
||||
{ targetId: remote.targetId, port: SCAN_REFRESH_PORT }
|
||||
),
|
||||
{ timeout: 45_000, message: 'scan-refresh listener was not detected in main' }
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(orcaPage.getByText(`:${SCAN_REFRESH_PORT}`, { exact: true })).toBeVisible()
|
||||
|
||||
await unrelatedLocalPortReservation.release()
|
||||
const unrelatedForward = await addPortForward(orcaPage, {
|
||||
targetId: remote.targetId,
|
||||
localPort: unrelatedLocalPort,
|
||||
remotePort: REFRESH_BARRIER_PORT,
|
||||
label: 'unrelated-listener'
|
||||
})
|
||||
await expectForwardEvidence(orcaPage, remote.targetId, [
|
||||
{ localPort, remotePort: REMOTE_PORT },
|
||||
{ localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT }
|
||||
])
|
||||
await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker)
|
||||
|
||||
await forceDockerSshRelayChannelReconnect(orcaPage, target, remote.targetId)
|
||||
await expectForwardEvidence(orcaPage, remote.targetId, [
|
||||
{ localPort, remotePort: REMOTE_PORT },
|
||||
{ localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT }
|
||||
])
|
||||
await expect.poll(() => requestForward(localPort)).toContain(marker)
|
||||
await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker)
|
||||
|
||||
const authorityBeforeTransportReconnect = await orcaPage.evaluate(
|
||||
(targetId) => window.__store?.getState().sshConnectionStates.get(targetId),
|
||||
remote.targetId
|
||||
)
|
||||
await reconnectDockerSshRelayTarget(orcaPage, remote.targetId)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await orcaPage.evaluate(
|
||||
(targetId) => window.__store?.getState().sshConnectionStates.get(targetId),
|
||||
remote.targetId
|
||||
)
|
||||
return (
|
||||
state?.status === 'connected' &&
|
||||
(state.providerEpoch !== authorityBeforeTransportReconnect?.providerEpoch ||
|
||||
state.connectionGeneration !==
|
||||
authorityBeforeTransportReconnect?.connectionGeneration)
|
||||
)
|
||||
},
|
||||
{ timeout: 30_000, message: 'renderer did not observe the reconnected SSH authority' }
|
||||
)
|
||||
.toBe(true)
|
||||
await expectForwardEvidence(orcaPage, remote.targetId, [
|
||||
{ localPort, remotePort: REMOTE_PORT },
|
||||
{ localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT }
|
||||
])
|
||||
await expect.poll(() => requestForward(localPort)).toContain(marker)
|
||||
await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker)
|
||||
await expect(
|
||||
orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
orcaPage.getByText(`:${unrelatedLocalPort} → :${REFRESH_BARRIER_PORT}`, { exact: true })
|
||||
).toBeVisible()
|
||||
|
||||
const collisionServer = createServer()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
collisionServer.once('error', reject)
|
||||
collisionServer.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const collisionAddress = collisionServer.address()
|
||||
if (!collisionAddress || typeof collisionAddress === 'string') {
|
||||
throw new Error('Unable to reserve a collision port')
|
||||
}
|
||||
try {
|
||||
const collisionResult = await orcaPage.evaluate(
|
||||
async ({ targetId, localPort, remotePort }) => {
|
||||
try {
|
||||
await window.api.ssh.addPortForward({
|
||||
targetId,
|
||||
localPort,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort,
|
||||
label: 'collision'
|
||||
})
|
||||
return { ok: true, message: '' }
|
||||
} catch (error) {
|
||||
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
},
|
||||
{
|
||||
targetId: remote.targetId,
|
||||
localPort: collisionAddress.port,
|
||||
remotePort: REMOTE_PORT
|
||||
}
|
||||
)
|
||||
expect(collisionResult).toMatchObject({ ok: false })
|
||||
expect(collisionResult.message).toMatch(/in use|EADDRINUSE/i)
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
collisionServer.close((error) => (error ? reject(error) : resolve()))
|
||||
)
|
||||
}
|
||||
await expectForwardEvidence(orcaPage, remote.targetId, [
|
||||
{ localPort, remotePort: REMOTE_PORT },
|
||||
{ localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT }
|
||||
])
|
||||
|
||||
const primaryRow = orcaPage
|
||||
.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true })
|
||||
.locator('../../..')
|
||||
await primaryRow.getByTitle('Remove').click()
|
||||
await expect(
|
||||
orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true })
|
||||
).not.toBeVisible()
|
||||
await expectForwardEvidence(orcaPage, remote.targetId, [
|
||||
{ localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT }
|
||||
])
|
||||
await expect(requestForward(localPort)).rejects.toThrow()
|
||||
await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker)
|
||||
|
||||
const evidence = await readPortForwardEvidence(orcaPage, remote.targetId)
|
||||
const identity = readRemoteListenerIdentity(target, REMOTE_PORT)
|
||||
const unrelatedIdentity = readRemoteListenerIdentity(target, REFRESH_BARRIER_PORT)
|
||||
const warnings = await readLifecycleWarnings(electronApp)
|
||||
const relayReconnectStates = await readSshStateCapture(orcaPage)
|
||||
testInfo.annotations.push({
|
||||
type: 'ssh-port-forward-evidence',
|
||||
description: JSON.stringify({
|
||||
evidence,
|
||||
identity,
|
||||
unrelatedIdentity,
|
||||
removedForwardLocalPort: localPort,
|
||||
unrelatedForward,
|
||||
relayReconnectStates,
|
||||
staleSnapshotEvidence,
|
||||
systemSshInvocations: readSystemSshInvocationKinds(systemSshInvocationLogPath),
|
||||
warnings
|
||||
})
|
||||
})
|
||||
|
||||
expect(identity).toMatchObject({
|
||||
pid: remotePid,
|
||||
executable: expect.stringContaining('/node'),
|
||||
command: expect.stringContaining(String(REMOTE_PORT))
|
||||
})
|
||||
expect(unrelatedIdentity).toMatchObject({
|
||||
pid: unrelatedRemotePid,
|
||||
executable: expect.stringContaining('/node'),
|
||||
command: expect.stringContaining(String(REFRESH_BARRIER_PORT))
|
||||
})
|
||||
expect(evidence.rendererForwards).not.toContainEqual(
|
||||
expect.objectContaining({ localPort, remotePort: REMOTE_PORT })
|
||||
)
|
||||
expect(evidence.managerForwards).not.toContainEqual(
|
||||
expect.objectContaining({ localPort, remotePort: REMOTE_PORT })
|
||||
)
|
||||
expect(evidence.persistedForwards).not.toContainEqual(
|
||||
expect.objectContaining({ localPort, remotePort: REMOTE_PORT })
|
||||
)
|
||||
expect(evidence.events.at(-1)?.forwards).toContainEqual(
|
||||
expect.objectContaining({
|
||||
localPort: unrelatedLocalPort,
|
||||
remotePort: REFRESH_BARRIER_PORT
|
||||
})
|
||||
)
|
||||
await expect(requestForward(unrelatedLocalPort)).resolves.toContain(unrelatedMarker)
|
||||
expect(warnings.filter((message) => message.includes('Port forward'))).toEqual([])
|
||||
} finally {
|
||||
await restoreSshPortForwardSnapshotHandler(electronApp).catch(() => undefined)
|
||||
await restoreLifecycleWarningCapture(electronApp).catch(() => undefined)
|
||||
await localPortReservation.release().catch(() => undefined)
|
||||
await unrelatedLocalPortReservation.release().catch(() => undefined)
|
||||
cleanupDockerSshRelayTarget(target)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user