diff --git a/src/renderer/src/web/web-runtime-connection-heartbeat-unsendable-probe.test.ts b/src/renderer/src/web/web-runtime-connection-heartbeat-unsendable-probe.test.ts new file mode 100644 index 00000000000..c47b20190db --- /dev/null +++ b/src/renderer/src/web/web-runtime-connection-heartbeat-unsendable-probe.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest' +import { WebRuntimeConnectionHeartbeat } from './web-runtime-connection-heartbeat' + +// A probe that cannot be written is the strongest evidence the link is gone. Gating the deadline on +// a successful send disarms the only branch that can declare the socket dead, so a saturated or +// half-open socket is never judged at all — the wedge fixed on the SSH transport in #17817. +describe('WebRuntimeConnectionHeartbeat when the probe cannot be sent', () => { + it('still declares the socket dead instead of watching it forever', () => { + let now = 0 + const socket = { readyState: 1, close: vi.fn() } as unknown as WebSocket + const handleDeadSocket = vi.fn() + const heartbeat = new WebRuntimeConnectionHeartbeat({ + now: () => now, + isDocumentVisible: () => true, + isConnected: () => true, + getSocket: () => socket, + // The saturated / half-open case: the send never leaves. + sendProbe: () => false, + handleDeadSocket + }) + + heartbeat.lastInboundFrameAt = 0 + heartbeat.lastHeartbeatTickAt = 0 + for (const tickAt of [10_000, 20_000, 30_000, 40_000, 50_000]) { + now = tickAt + heartbeat.runTick() + } + + expect(handleDeadSocket).toHaveBeenCalledWith(socket) + }) +}) diff --git a/src/renderer/src/web/web-runtime-connection-heartbeat.ts b/src/renderer/src/web/web-runtime-connection-heartbeat.ts index 251d97945a6..177d7f91454 100644 --- a/src/renderer/src/web/web-runtime-connection-heartbeat.ts +++ b/src/renderer/src/web/web-runtime-connection-heartbeat.ts @@ -69,9 +69,13 @@ export class WebRuntimeConnectionHeartbeat { return } if (this.heartbeatProbeSentAt === null && now - this.lastInboundFrameAt >= HEARTBEAT_IDLE_MS) { - if (this.options.sendProbe()) { - this.heartbeatProbeSentAt = now - } + // Why the deadline is armed before the send and regardless of its result: a probe that could + // not be written is the strongest evidence the link is gone, not a reason to stop watching. + // Gating this on a successful send disarms the only branch above that can declare the socket + // dead, so a saturated or half-open socket would never be judged at all -- the same wedge + // fixed on the SSH transport in #17817. See also #17823. + this.heartbeatProbeSentAt = now + this.options.sendProbe() } }