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() {}