diff --git a/docs/gateway/bonjour.md b/docs/gateway/bonjour.md index d6d2602684d..0e11c39a5a9 100644 --- a/docs/gateway/bonjour.md +++ b/docs/gateway/bonjour.md @@ -137,6 +137,7 @@ The Gateway writes a rolling log file (printed on startup as `gateway log file: ...`). Look for `bonjour:` lines, especially: - `bonjour: advertise failed ...` +- `bonjour: suppressing ciao cancellation ...` - `bonjour: ... name conflict resolved` / `hostname conflict resolved` - `bonjour: watchdog detected non-announced service ...` - `bonjour: disabling advertiser after ... failed restarts ...` diff --git a/extensions/bonjour/src/advertiser.test.ts b/extensions/bonjour/src/advertiser.test.ts index c3d19306d75..f97673bb331 100644 --- a/extensions/bonjour/src/advertiser.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -393,11 +393,11 @@ describe("gateway bonjour advertiser", () => { expect(exceptionHandler).toBeTypeOf("function"); expect(handler?.(new Error("CIAO PROBING CANCELLED"))).toBe(true); - expect(logger.debug).toHaveBeenCalledWith( - expect.stringContaining("ignoring unhandled ciao rejection"), + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("suppressing ciao cancellation"), ); - logger.debug.mockClear(); + logger.warn.mockClear(); expect( handler?.(new Error("Reached illegal state! IPV4 address change from defined to undefined!")), ).toBe(true); @@ -423,6 +423,37 @@ describe("gateway bonjour advertiser", () => { await started.stop(); }); + it("recovers when ciao cancellation escapes the advertiser", async () => { + enableAdvertiserUnitMode(); + + const destroy = vi.fn().mockResolvedValue(undefined); + const advertise = vi.fn().mockResolvedValue(undefined); + mockCiaoService({ advertise, destroy }); + + const started = await startAdvertiser({ + gatewayPort: 18789, + sshPort: 2222, + }); + + const handler = registerUnhandledRejectionHandler.mock.calls[0]?.[0] as + | ((reason: unknown) => boolean) + | undefined; + expect(handler?.(new Error("CIAO ANNOUNCEMENT CANCELLED"))).toBe(true); + + await vi.waitFor(() => { + expect(createService).toHaveBeenCalledTimes(2); + }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("suppressing ciao cancellation"), + ); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("restarting advertiser")); + expect(destroy).toHaveBeenCalledTimes(1); + expect(advertise).toHaveBeenCalledTimes(2); + + await started.stop(); + }); + it("logs advertise failures and retries via watchdog", async () => { enableAdvertiserUnitMode(); vi.useFakeTimers(); diff --git a/extensions/bonjour/src/advertiser.ts b/extensions/bonjour/src/advertiser.ts index dbcc2cd52be..f3c0f8d367d 100644 --- a/extensions/bonjour/src/advertiser.ts +++ b/extensions/bonjour/src/advertiser.ts @@ -363,7 +363,8 @@ export async function startGatewayBonjourAdvertiser( } if (classification.kind === "cancellation") { - logger.debug(`bonjour: ignoring unhandled ciao rejection: ${classification.formatted}`); + logger.warn(`bonjour: suppressing ciao cancellation: ${classification.formatted}`); + requestCiaoRecovery?.(classification); } else if (classification.kind === "interface-enumeration-failure") { // Restricted sandboxes can refuse os.networkInterfaces(); mDNS cannot // function without it, so surface a single warning and skip recovery. @@ -490,6 +491,28 @@ export async function startGatewayBonjourAdvertiser( } } + function handleAdvertiseFailure( + label: string, + svc: BonjourService, + err: unknown, + action: "failed" | "threw", + ) { + const classification = classifyCiaoProcessError(err); + if (classification) { + logger.warn( + `bonjour: advertise ${action} with ciao ${classification.kind} (${serviceSummary( + label, + svc, + )}): ${classification.formatted}`, + ); + requestCiaoRecovery?.(classification); + return; + } + logger.warn( + `bonjour: advertise ${action} (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`, + ); + } + function startAdvertising(services: Array<{ label: string; svc: BonjourService }>) { for (const { label, svc } of services) { try { @@ -499,14 +522,10 @@ export async function startGatewayBonjourAdvertiser( logger.info(`bonjour: advertised ${serviceSummary(label, svc)}`); }) .catch((err) => { - logger.warn( - `bonjour: advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`, - ); + handleAdvertiseFailure(label, svc, err, "failed"); }); } catch (err) { - logger.warn( - `bonjour: advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`, - ); + handleAdvertiseFailure(label, svc, err, "threw"); } } } @@ -523,8 +542,6 @@ export async function startGatewayBonjourAdvertiser( let consecutiveRestarts = 0; let cycle: BonjourCycle | null = createCycle(); const stateTracker = new Map(); - attachConflictListeners(cycle.services); - startAdvertising(cycle.services); const updateStateTrackers = (services: Array<{ label: string; svc: BonjourService }>) => { const now = Date.now(); @@ -578,6 +595,8 @@ export async function startGatewayBonjourAdvertiser( requestCiaoRecovery = (classification) => { void recreateAdvertiser(`ciao ${classification.kind}: ${classification.formatted}`); }; + attachConflictListeners(cycle.services); + startAdvertising(cycle.services); const lastRepairAttempt = new Map(); const watchdog = setInterval(() => {