fix: release ClawHub lifecycle state on CLI exit (#114090)

This commit is contained in:
Peter Steinberger
2026-07-26 06:39:45 -04:00
committed by GitHub
parent 9eae43bd37
commit 53e3f296b1
4 changed files with 63 additions and 5 deletions

View File

@@ -5,6 +5,11 @@ import { resolvePluginUninstallId } from "./plugins-uninstall-selection.js";
describe("resolvePluginUninstallId", () => {
it("accepts the recorded ClawHub spec as an uninstall target", () => {
const plugin = {
id: "linkmind-context",
name: "linkmind-context",
channelIds: ["linkmind-channel"],
};
const result = resolvePluginUninstallId({
rawId: "clawhub:linkmind-context",
config: {
@@ -21,13 +26,19 @@ describe("resolvePluginUninstallId", () => {
},
},
} as OpenClawConfig,
plugins: [{ id: "linkmind-context", name: "linkmind-context" }],
plugins: [plugin],
});
expect(result.pluginId).toBe("linkmind-context");
expect(result.plugin).toBe(plugin);
});
it("accepts a versionless ClawHub spec when the install was pinned", () => {
const plugin = {
id: "linkmind-context",
name: "linkmind-context",
channelIds: ["linkmind-channel"],
};
const result = resolvePluginUninstallId({
rawId: "clawhub:linkmind-context",
config: {
@@ -43,9 +54,10 @@ describe("resolvePluginUninstallId", () => {
},
},
} as OpenClawConfig,
plugins: [{ id: "linkmind-context", name: "linkmind-context" }],
plugins: [plugin],
});
expect(result.pluginId).toBe("linkmind-context");
expect(result.plugin).toBe(plugin);
});
});

View File

@@ -12,6 +12,10 @@ export function resolvePluginUninstallId<
plugins: TPlugin[];
}): { pluginId: string; plugin?: TPlugin } {
const rawId = params.rawId.trim();
const resolveInstalledPlugin = (pluginId: string) => {
const plugin = params.plugins.find((entry) => entry.id === pluginId);
return plugin ? { pluginId, plugin } : { pluginId };
};
const plugin = params.plugins.find((entry) => entry.id === rawId || entry.name === rawId);
if (plugin) {
return { pluginId: plugin.id, plugin };
@@ -24,7 +28,7 @@ export function resolvePluginUninstallId<
install.resolvedName === rawId ||
install.marketplacePlugin === rawId
) {
return { pluginId };
return resolveInstalledPlugin(pluginId);
}
}
@@ -36,7 +40,7 @@ export function resolvePluginUninstallId<
parseClawHubPluginSpec(install.spec ?? "")?.name ??
parseClawHubPluginSpec(install.resolvedSpec ?? "")?.name;
if (installedClawHubName === requestedClawHub.name) {
return { pluginId };
return resolveInstalledPlugin(pluginId);
}
}
}

View File

@@ -10,7 +10,10 @@ import {
} from "../claws/provenance.js";
import type { ClawAddPlan } from "../claws/types.js";
import { markClawPackageIndependentlyOwned } from "./claw-package-adoption.js";
import { acquireClawPackageLifecycleLease } from "./claw-package-lifecycle-lease.js";
import {
acquireClawPackageLifecycleLease,
withClawPackageLifecycleLease,
} from "./claw-package-lifecycle-lease.js";
import { closeOpenClawStateDatabaseForTest } from "./openclaw-state-db.js";
afterEach(() => closeOpenClawStateDatabaseForTest());
@@ -237,6 +240,34 @@ describe("Claw package independent adoption", () => {
directLease?.release();
});
it("releases a package lease when process exit bypasses async cleanup", async () => {
const env = { OPENCLAW_STATE_DIR: tempDirs.make("claw-exit-lease-") };
const artifact = { kind: "plugin", source: "clawhub", ref: "@acme/audit" } as const;
const existingExitListeners = new Set(process.listeners("exit"));
await expect(
withClawPackageLifecycleLease(
artifact,
async () => {
const exitCleanup = process
.listeners("exit")
.find((listener) => !existingExitListeners.has(listener));
expect(exitCleanup).toBeTypeOf("function");
exitCleanup?.(1);
throw new Error("simulated process exit");
},
{ env, required: true },
),
).rejects.toThrow("simulated process exit");
expect(
process.listeners("exit").filter((listener) => !existingExitListeners.has(listener)),
).toEqual([]);
const nextLease = acquireClawPackageLifecycleLease(artifact, { env, required: true });
expect(nextLease).not.toBeNull();
nextLease?.release();
});
it("fails open only for optional direct leases when lifecycle state is unavailable", () => {
const invalidDatabasePath = tempDirs.make("claw-invalid-db-path-");
const artifact = { kind: "plugin", source: "clawhub", ref: "@acme/audit" } as const;

View File

@@ -205,11 +205,22 @@ export async function withClawPackageLifecycleLease<T>(
return await operation();
}
const maintained = maintainClawPackageLifecycleLease(lease);
// CLI failures call process.exit(), which skips async finally blocks. Release
// synchronously on exit so the next package command is not blocked until TTL.
const releaseOnExit = () => {
try {
maintained.release();
} catch {
// Expiry recovers a lease whose exit cleanup loses a database race.
}
};
process.once("exit", releaseOnExit);
try {
const result = await operation();
maintained.assertCurrent();
return result;
} finally {
process.removeListener("exit", releaseOnExit);
try {
maintained.release();
} catch {