diff --git a/src/relay/windows-port-scan.test.ts b/src/relay/windows-port-scan.test.ts index c723b6f5c41..96a1894683b 100644 --- a/src/relay/windows-port-scan.test.ts +++ b/src/relay/windows-port-scan.test.ts @@ -14,7 +14,10 @@ import { __setWindowsProcessTreeLoaderForTests, resetWindowsProcessTableForTests } from '../main/windows/windows-process-table' -import { scanWindowsListeningPorts } from './windows-port-scan' +import { + resetWindowsPortScanDiagnosticsForTests, + scanWindowsListeningPorts +} from './windows-port-scan' type Spec = { program: string @@ -85,6 +88,7 @@ function specs(): Spec[] { describe('scanWindowsListeningPorts', () => { beforeEach(() => { runProcessMock.mockReset() + resetWindowsPortScanDiagnosticsForTests() resetWindowsProcessTableForTests() __setWindowsProcessTreeLoaderForTests( nativeTable([ @@ -310,6 +314,33 @@ describe('scanWindowsListeningPorts', () => { expect(getAllProcesses).not.toHaveBeenCalled() }) + // The relay daemon's stderr is what installRelayLogRotation routes into + // relay.log, so a fall-through logged anywhere else is a fall-through nobody + // can diagnose. Pin the stream, not just the fact that something was called. + it('reports leaving the native path on the relay diagnostic stream, once', async () => { + const lines: string[] = [] + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: string | Uint8Array) => { + lines.push(String(chunk)) + return true + }) + try { + runProcessMock.mockResolvedValue(ok('')) + await scanWindowsListeningPorts() + await scanWindowsListeningPorts() + } finally { + stderr.mockRestore() + } + + const reported = lines.filter((line) => line.includes('[ports] netstat unusable')) + expect(reported).toHaveLength(1) + expect(reported[0]).toContain('no listening row parsed') + // relayLogLine's ISO stamp: an unplaceable line cannot be read against the + // reconnect flaps around it. + expect(reported[0]).toMatch(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z /) + }) + it('treats a netstat timeout as unanswered and falls through', async () => { runProcessMock .mockResolvedValueOnce({ diff --git a/src/relay/windows-port-scan.ts b/src/relay/windows-port-scan.ts index e62e0ad7711..1e851848f60 100644 --- a/src/relay/windows-port-scan.ts +++ b/src/relay/windows-port-scan.ts @@ -7,6 +7,7 @@ import { import { getProcessOutputFields } from '../shared/process-output-field-scanner' import type { DetectedPort } from './port-scan-handler' import { buildRelayCommandEnv } from './relay-command-env' +import { relayLogLine } from './relay-diagnostic-log' const SYSTEM_PORTS_TO_EXCLUDE = new Set([22]) const MAX_DETECTED_PORTS = 50 @@ -91,13 +92,24 @@ let reportedNetstatUnusable = false * Both fall-throughs are permanent when they are wrong — the host stays on the * PowerShell payload, or on nothing, for the life of the relay — and the scan * repeats every 12-30s, so this logs one line rather than a stream. + * + * Through relayLogLine, not console.warn: this only ever runs in the detached + * daemon, whose stderr installRelayLogRotation routes into the relay.log that + * the remote-diagnostics tail reads. An untimestamped line in that file cannot + * be placed against the reconnect flaps around it (#7773), and "since when" is + * most of what this line is for. */ function reportWindowsNetstatUnusable(reason: string): void { if (reportedNetstatUnusable) { return } reportedNetstatUnusable = true - console.warn(`[ports] netstat unusable on this host (${reason}); falling back to PowerShell`) + relayLogLine(`[ports] netstat unusable on this host (${reason}); falling back to PowerShell`) +} + +/** Test-only: re-arm the one-shot so each case can observe its own line. */ +export function resetWindowsPortScanDiagnosticsForTests(): void { + reportedNetstatUnusable = false } /**