diff --git a/src/infra/bonjour-ciao.test.ts b/src/infra/bonjour-ciao.test.ts index ad45974e2fc..9f0ccc6d3e4 100644 --- a/src/infra/bonjour-ciao.test.ts +++ b/src/infra/bonjour-ciao.test.ts @@ -1,70 +1,51 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; -const logDebugMock = vi.hoisted(() => vi.fn()); -const logWarnMock = vi.hoisted(() => vi.fn()); - -vi.mock("../logger.js", () => ({ - logDebug: (...args: unknown[]) => logDebugMock(...args), - logWarn: (...args: unknown[]) => logWarnMock(...args), -})); - -const { ignoreCiaoUnhandledRejection } = await import("./bonjour-ciao.js"); +const { classifyCiaoUnhandledRejection, ignoreCiaoUnhandledRejection } = + await import("./bonjour-ciao.js"); describe("bonjour-ciao", () => { - it("ignores and logs ciao announcement cancellation rejections", () => { + it("classifies ciao cancellation rejections separately from side effects", () => { + expect(classifyCiaoUnhandledRejection(new Error("CIAO PROBING CANCELLED"))).toEqual({ + kind: "cancellation", + formatted: "CIAO PROBING CANCELLED", + }); + }); + + it("classifies ciao interface assertions separately from side effects", () => { + expect( + classifyCiaoUnhandledRejection( + new Error("Reached illegal state! IPV4 address change from defined to undefined!"), + ), + ).toEqual({ + kind: "interface-assertion", + formatted: "Reached illegal state! IPV4 address change from defined to undefined!", + }); + }); + + it("suppresses ciao announcement cancellation rejections", () => { expect(ignoreCiaoUnhandledRejection(new Error("Ciao announcement cancelled by shutdown"))).toBe( true, ); - expect(logDebugMock).toHaveBeenCalledWith( - expect.stringContaining("ignoring unhandled ciao rejection"), - ); - expect(logWarnMock).not.toHaveBeenCalled(); }); - it("ignores and logs ciao probing cancellation rejections", () => { - logDebugMock.mockReset(); - logWarnMock.mockReset(); - + it("suppresses ciao probing cancellation rejections", () => { expect(ignoreCiaoUnhandledRejection(new Error("CIAO PROBING CANCELLED"))).toBe(true); - expect(logDebugMock).toHaveBeenCalledWith( - expect.stringContaining("ignoring unhandled ciao rejection"), - ); - expect(logWarnMock).not.toHaveBeenCalled(); }); - it("ignores lower-case string cancellation reasons too", () => { - logDebugMock.mockReset(); - logWarnMock.mockReset(); - + it("suppresses lower-case string cancellation reasons too", () => { expect(ignoreCiaoUnhandledRejection("ciao announcement cancelled during cleanup")).toBe(true); - expect(logDebugMock).toHaveBeenCalledWith( - expect.stringContaining("ignoring unhandled ciao rejection"), - ); - expect(logWarnMock).not.toHaveBeenCalled(); }); it("suppresses ciao interface assertion rejections as non-fatal", () => { - logDebugMock.mockReset(); - logWarnMock.mockReset(); - const error = Object.assign( new Error("Reached illegal state! IPV4 address change from defined to undefined!"), { name: "AssertionError" }, ); expect(ignoreCiaoUnhandledRejection(error)).toBe(true); - expect(logWarnMock).toHaveBeenCalledWith( - expect.stringContaining("suppressing ciao interface assertion"), - ); - expect(logDebugMock).not.toHaveBeenCalled(); }); it("keeps unrelated rejections visible", () => { - logDebugMock.mockReset(); - logWarnMock.mockReset(); - expect(ignoreCiaoUnhandledRejection(new Error("boom"))).toBe(false); - expect(logDebugMock).not.toHaveBeenCalled(); - expect(logWarnMock).not.toHaveBeenCalled(); }); }); diff --git a/src/infra/bonjour-ciao.ts b/src/infra/bonjour-ciao.ts index 71feebd60bc..34ddb4b75fe 100644 --- a/src/infra/bonjour-ciao.ts +++ b/src/infra/bonjour-ciao.ts @@ -1,21 +1,27 @@ -import { logDebug, logWarn } from "../logger.js"; import { formatBonjourError } from "./bonjour-errors.js"; const CIAO_CANCELLATION_MESSAGE_RE = /^CIAO (?:ANNOUNCEMENT|PROBING) CANCELLED\b/u; const CIAO_INTERFACE_ASSERTION_MESSAGE_RE = /REACHED ILLEGAL STATE!?\s+IPV4 ADDRESS CHANGE FROM DEFINED TO UNDEFINED!?/u; -export function ignoreCiaoUnhandledRejection(reason: unknown): boolean { +export type CiaoUnhandledRejectionClassification = + | { kind: "cancellation"; formatted: string } + | { kind: "interface-assertion"; formatted: string }; + +export function classifyCiaoUnhandledRejection( + reason: unknown, +): CiaoUnhandledRejectionClassification | null { const formatted = formatBonjourError(reason); const message = formatted.toUpperCase(); - if (!CIAO_CANCELLATION_MESSAGE_RE.test(message)) { - if (!CIAO_INTERFACE_ASSERTION_MESSAGE_RE.test(message)) { - return false; - } - - logWarn(`bonjour: suppressing ciao interface assertion: ${formatted}`); - return true; + if (CIAO_CANCELLATION_MESSAGE_RE.test(message)) { + return { kind: "cancellation", formatted }; } - logDebug(`bonjour: ignoring unhandled ciao rejection: ${formatted}`); - return true; + if (CIAO_INTERFACE_ASSERTION_MESSAGE_RE.test(message)) { + return { kind: "interface-assertion", formatted }; + } + return null; +} + +export function ignoreCiaoUnhandledRejection(reason: unknown): boolean { + return classifyCiaoUnhandledRejection(reason) !== null; } diff --git a/src/infra/bonjour.test.ts b/src/infra/bonjour.test.ts index b101171782b..fbb80603bb3 100644 --- a/src/infra/bonjour.test.ts +++ b/src/infra/bonjour.test.ts @@ -241,6 +241,39 @@ describe("gateway bonjour advertiser", () => { expect(order).toEqual(["shutdown", "cleanup"]); }); + it("logs ciao handler classifications at the bonjour caller", async () => { + enableAdvertiserUnitMode(); + + const destroy = vi.fn().mockResolvedValue(undefined); + const advertise = vi.fn().mockResolvedValue(undefined); + mockCiaoService({ advertise, destroy }); + + const started = await startGatewayBonjourAdvertiser({ + gatewayPort: 18789, + sshPort: 2222, + }); + + const handler = registerUnhandledRejectionHandler.mock.calls[0]?.[0] as + | ((reason: unknown) => boolean) + | undefined; + expect(handler).toBeTypeOf("function"); + + expect(handler?.(new Error("CIAO PROBING CANCELLED"))).toBe(true); + expect(logDebug).toHaveBeenCalledWith( + expect.stringContaining("ignoring unhandled ciao rejection"), + ); + + logDebug.mockClear(); + expect( + handler?.(new Error("Reached illegal state! IPV4 address change from defined to undefined!")), + ).toBe(true); + expect(logWarn).toHaveBeenCalledWith( + expect.stringContaining("suppressing ciao interface assertion"), + ); + + await started.stop(); + }); + it("logs advertise failures and retries via watchdog", async () => { enableAdvertiserUnitMode(); vi.useFakeTimers(); diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index a4a21a60405..d2b8344a047 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -1,6 +1,6 @@ import { logDebug, logWarn } from "../logger.js"; import { getLogger } from "../logging.js"; -import { ignoreCiaoUnhandledRejection } from "./bonjour-ciao.js"; +import { classifyCiaoUnhandledRejection } from "./bonjour-ciao.js"; import { formatBonjourError } from "./bonjour-errors.js"; import { isTruthyEnvValue } from "./env.js"; import { registerUnhandledRejectionHandler } from "./unhandled-rejections.js"; @@ -94,6 +94,21 @@ function isAnnouncedState(state: BonjourServiceState | "unknown") { return String(state) === "announced"; } +function handleCiaoUnhandledRejection(reason: unknown): boolean { + const classification = classifyCiaoUnhandledRejection(reason); + if (!classification) { + return false; + } + + if (classification.kind === "interface-assertion") { + logWarn(`bonjour: suppressing ciao interface assertion: ${classification.formatted}`); + return true; + } + + logDebug(`bonjour: ignoring unhandled ciao rejection: ${classification.formatted}`); + return true; +} + export async function startGatewayBonjourAdvertiser( opts: GatewayBonjourAdvertiseOpts, ): Promise { @@ -175,7 +190,7 @@ export async function startGatewayBonjourAdvertiser( const cleanupUnhandledRejection = services.length > 0 - ? registerUnhandledRejectionHandler(ignoreCiaoUnhandledRejection) + ? registerUnhandledRejectionHandler(handleCiaoUnhandledRejection) : undefined; return { responder, services, cleanupUnhandledRejection };