From 02adae8499e3f538b009fd8deb63691f66b90bb6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Jul 2026 15:00:09 +0200 Subject: [PATCH] fix(hub): contain enrollment retry failures --- .../hub/relationship-controller.test.ts | 31 +++++++++++++++++++ .../src/server/hub/relationship-controller.ts | 5 ++- .../hub/test-utils/relationship-harness.ts | 9 ++++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index febc6456c8..10b3d654bb 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -3,6 +3,19 @@ import { platform } from "node:os"; import { afterEach, describe, expect, test } from "vitest"; import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; +async function captureUnhandledRejections(action: () => Promise): Promise { + const rejections: unknown[] = []; + const capture = (reason: unknown) => rejections.push(reason); + process.on("unhandledRejection", capture); + try { + await action(); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.off("unhandledRejection", capture); + } + return rejections; +} + describe("Hub relationship", () => { let relationship: HubRelationshipHarness | null = null; @@ -211,6 +224,24 @@ describe("Hub relationship", () => { expect(relationship.relationshipFile()).toBeNull(); }); + test("a scheduled enrollment rejection is contained after removing pending authority", async () => { + relationship = await HubRelationshipHarness.start(); + relationship.holdEnrollment(); + const connecting = relationship.beginConnect("expired-token"); + await relationship.enrollmentBegins(); + relationship.loseEnrollmentResponse(); + await connecting.result; + relationship.rejectNextEnrollment(403); + + const unhandledRejections = await captureUnhandledRejections(() => relationship!.retry()); + const status = await relationship.status(); + + expect(status).toMatchObject({ state: "not_connected" }); + expect(relationship.relationshipFile()).toBeNull(); + expect(relationship.pendingRelationshipRetries()).toBe(0); + expect(unhandledRejections).toEqual([]); + }); + test.each(["{not-json", JSON.stringify({ version: 1, state: "unknown" })])( "an invalid relationship file is quarantined without blocking daemon startup", async (contents) => { diff --git a/packages/server/src/server/hub/relationship-controller.ts b/packages/server/src/server/hub/relationship-controller.ts index 0dc7a1a5b9..45caeeaf64 100644 --- a/packages/server/src/server/hub/relationship-controller.ts +++ b/packages/server/src/server/hub/relationship-controller.ts @@ -394,7 +394,10 @@ export class HubRelationshipController implements HubRelationshipManagement { const delay = this.retryPolicy.delay(this.retryAttempt++); this.retry = this.clock.schedule(delay, () => { if (enrollmentGeneration !== this.enrollmentGeneration) return; - void this.tryEnrollment(record, enrollmentGeneration); + void this.tryEnrollment(record, enrollmentGeneration).catch((error: unknown) => { + if (error instanceof HubEnrollmentRejectedError) return; + this.options.logger.error({ err: error }, "Scheduled Hub enrollment retry failed"); + }); }); } diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index 78ffd2dfe6..5da47b43fe 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -1283,13 +1283,18 @@ export class HubRelationshipHarness { } private async removeRoot(): Promise { - const attempts = platform() === "win32" ? 10 : 1; + let retryableCode: string | null = null; + if (platform() === "win32") retryableCode = "EBUSY"; + if (platform() === "darwin") retryableCode = "ENOTEMPTY"; + const attempts = retryableCode ? 10 : 1; for (let attempt = 1; attempt <= attempts; attempt++) { try { await rm(this.root, { recursive: true, force: true }); return; } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EBUSY" || attempt === attempts) throw error; + if ((error as NodeJS.ErrnoException).code !== retryableCode || attempt === attempts) { + throw error; + } await new Promise((resolve) => setTimeout(resolve, 100 * attempt)); } }