Disconnect daemons locally when Hub is unreachable (#3321)

* fix(hub): make daemon disconnect unilateral

Local relationship removal must not depend on reaching the Hub. Fence execution authority, make one bounded courtesy revocation attempt, then clear local state so status and reconnect eligibility agree immediately.

* fix(hub): preserve disconnect intent across crashes

Persist the terminal disconnect marker only while the bounded Hub notification is in flight. Startup removes that marker, so a crash cannot restore the old relationship or create a revocation retry loop.
This commit is contained in:
Mohamed Boudra
2026-08-13 14:00:37 +02:00
committed by GitHub
parent 9ca6f24315
commit d0d60a4052
7 changed files with 195 additions and 72 deletions
+29 -4
View File
@@ -344,7 +344,6 @@ describe("Hub commands", () => {
daemon.beforeDisconnect = () => {
assert.equal(credentials.active()?.credential, "human-secret");
};
const result = await runHubLogout(
{ json: true, disconnectDaemon: true, force: true },
{
@@ -359,9 +358,31 @@ describe("Hub commands", () => {
assert.equal(daemon.disconnects, 1);
assert.deepEqual(daemon.disconnectForces, [true]);
assert.equal(result.data.daemonDisconnected, true);
assert.equal(result.data.warning, undefined);
});
it("disconnect reports the current Hub and preserves it in the final result", async () => {
it("logout reports a daemon disconnect warning", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "human-secret" });
const daemon = new FakeDaemon("https://hub.test");
daemon.disconnectWarning = "Hub could not be reached; cleanup may remain pending.";
const result = await runHubLogout(
{ json: true, disconnectDaemon: true },
{
credentials,
daemon: new FakeDaemonConnection(daemon),
isInteractive: () => false,
confirmDisconnect: async () => false,
reporter: quietReporter,
},
);
assert.equal(result.data.daemonDisconnected, true);
assert.equal(result.data.warning, "Hub could not be reached; cleanup may remain pending.");
});
it("disconnect reports clean local status without a ghost Hub origin", async () => {
const daemon = new FakeDaemon("https://hub.test");
const progress: string[] = [];
@@ -374,7 +395,7 @@ describe("Hub commands", () => {
);
assert.deepEqual(progress, ["Disconnecting this daemon from https://hub.test"]);
assert.equal(result.data[0]?.hub, "https://hub.test");
assert.equal(result.data[0]?.hub, null);
});
it("preserves login when an accepted daemon disconnect fails", async () => {
@@ -448,6 +469,7 @@ class FakeDaemon implements HubDaemonClient {
disconnects = 0;
readonly disconnectForces: boolean[] = [];
disconnectError: Error | null = null;
disconnectWarning: string | null = null;
beforeDisconnect: (() => void) | null = null;
constructor(private readonly origin: string) {}
@@ -466,7 +488,10 @@ class FakeDaemon implements HubDaemonClient {
this.disconnectForces.push(force);
this.beforeDisconnect?.();
if (this.disconnectError !== null) throw this.disconnectError;
return { status: hubStatus("not_connected", null) };
return {
status: hubStatus("not_connected", null),
...(this.disconnectWarning ? { warning: this.disconnectWarning } : {}),
};
}
async close() {}
+2 -2
View File
@@ -31,7 +31,7 @@ export function runHubDisconnect(
);
}
const response = await client.disconnectHub(options.force ?? false);
return hubStatusResult(response.status, response.warning, current.hubOrigin);
return hubStatusResult(response.status, response.warning);
});
}
@@ -42,7 +42,7 @@ export function addHubDisconnectCommand(
addJsonAndDaemonHostOptions(
parent
.command("disconnect")
.option("--force", "Remove local authority even if the Hub is offline"),
.option("--force", "Remove local authority without notifying the Hub"),
).action(
withOutput(async (...args) => {
const options = args.at(-2) as HubDisconnectOptions;
+11 -4
View File
@@ -11,6 +11,7 @@ interface HubLogoutResult {
origin: string | null;
status: "logged_out" | "not_logged_in";
daemonDisconnected: boolean;
warning?: string;
}
const schema: OutputSchema<HubLogoutResult> = {
@@ -19,6 +20,7 @@ const schema: OutputSchema<HubLogoutResult> = {
{ header: "HUB", field: "origin" },
{ header: "STATUS", field: "status" },
{ header: "DAEMON DISCONNECTED", field: "daemonDisconnected" },
{ header: "WARNING", field: "warning" },
],
};
@@ -70,11 +72,16 @@ export async function runHubLogout(
options,
`Disconnecting this daemon from ${active.origin}`,
);
await withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
await daemon.disconnectHub(options.force ?? false);
const disconnect = await withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
return daemon.disconnectHub(options.force ?? false);
});
dependencies.credentials.logoutActive();
return logoutResult({ origin: active.origin, status: "logged_out", daemonDisconnected: true });
return logoutResult({
origin: active.origin,
status: "logged_out",
daemonDisconnected: disconnect.status.state === "not_connected",
...(disconnect.warning ? { warning: disconnect.warning } : {}),
});
}
export function addHubLogoutCommand(parent: Command, dependencies: HubLogoutDependencies): void {
@@ -83,7 +90,7 @@ export function addHubLogoutCommand(parent: Command, dependencies: HubLogoutDepe
.command("logout")
.description("Remove the active stored Hub CLI login")
.option("--disconnect-daemon", "Also disconnect a daemon related to the same Hub")
.option("--force", "Remove daemon authority even if Hub is offline"),
.option("--force", "Remove daemon authority without notifying the Hub"),
).action(
withOutput(async (...args) => {
const options = args.at(-2) as HubLogoutOptions;