fix(marketplace): close release lifecycle gaps
This commit is contained in:
@@ -135,7 +135,7 @@ export function createCodingComposition(
|
||||
clientVersion: options.clientVersion ?? '2.0.0',
|
||||
});
|
||||
const activePluginReleaseCounts = new Map<string, number>();
|
||||
const registerActivePluginReleases = (releaseIds: readonly string[]): (() => void) => {
|
||||
const registerActivePluginReleases = (releaseIds: readonly string[]): (() => Promise<void>) => {
|
||||
const uniqueReleaseIds = [...new Set(releaseIds)];
|
||||
for (const releaseId of uniqueReleaseIds) {
|
||||
const count = activePluginReleaseCounts.get(releaseId) ?? 0;
|
||||
@@ -143,18 +143,20 @@ export function createCodingComposition(
|
||||
activePluginReleaseCounts.set(releaseId, count + 1);
|
||||
}
|
||||
let released = false;
|
||||
return () => {
|
||||
return async () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const cleanup: Array<Promise<void>> = [];
|
||||
for (const releaseId of uniqueReleaseIds) {
|
||||
const count = activePluginReleaseCounts.get(releaseId) ?? 0;
|
||||
if (count <= 1) {
|
||||
activePluginReleaseCounts.delete(releaseId);
|
||||
packageStore.releaseActiveWorker(releaseId);
|
||||
cleanup.push(packageStore.releaseActiveWorker(releaseId));
|
||||
} else {
|
||||
activePluginReleaseCounts.set(releaseId, count - 1);
|
||||
}
|
||||
}
|
||||
await Promise.all(cleanup);
|
||||
};
|
||||
};
|
||||
let effectiveResolver: EffectivePluginResolver | undefined;
|
||||
|
||||
@@ -100,7 +100,7 @@ export interface InstalledReleaseRecord {
|
||||
readonly sha256: string;
|
||||
readonly sizeBytes: number;
|
||||
readonly installedAt: string;
|
||||
/** Channel and verified client range are immutable facts of this install. */
|
||||
/** Channel is the latest explicit selection intent; verified client range is immutable. */
|
||||
readonly channel?: 'stable' | 'beta';
|
||||
readonly minMakeloreVersion?: string;
|
||||
readonly maxMakeloreVersion?: string | null;
|
||||
@@ -628,6 +628,7 @@ export class PluginPackageStore {
|
||||
private readonly writeIndex: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
||||
private readonly activeWorkerReleaseIdsImpl: (() => readonly string[]) | null;
|
||||
private readonly activeWorkers = new Set<string>();
|
||||
private readonly pendingExplicitCleanupPluginIds = new Set<string>();
|
||||
private readonly unsubscribeSession: (() => void) | null;
|
||||
private operation: Promise<void> = Promise.resolve();
|
||||
|
||||
@@ -691,7 +692,11 @@ export class PluginPackageStore {
|
||||
fail('plugin_beta_selection_required');
|
||||
}
|
||||
const binding = this.requireBinding();
|
||||
return this.withOperation(() => this.resolveAndInstallLocked(input, binding));
|
||||
return this.withOperation(async () => {
|
||||
const result = await this.resolveAndInstallLocked(input, binding);
|
||||
this.pendingExplicitCleanupPluginIds.delete(input.pluginId);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async getInstalled(pluginId: string): Promise<InstalledRelease | null> {
|
||||
@@ -714,7 +719,13 @@ export class PluginPackageStore {
|
||||
this.assertBinding(binding);
|
||||
this.accountCache.invalidatePlugin(binding, validated);
|
||||
this.assertBinding(binding);
|
||||
return this.removeUnusedLocked(validated, binding, 'explicit');
|
||||
const result = await this.removeUnusedLocked(validated, binding, 'explicit');
|
||||
if (result.status === 'kept' && result.reason === 'active_worker_reference') {
|
||||
this.pendingExplicitCleanupPluginIds.add(validated);
|
||||
} else {
|
||||
this.pendingExplicitCleanupPluginIds.delete(validated);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -722,8 +733,15 @@ export class PluginPackageStore {
|
||||
this.activeWorkers.add(validReleaseId(releaseId));
|
||||
}
|
||||
|
||||
releaseActiveWorker(releaseId: string): void {
|
||||
async releaseActiveWorker(releaseId: string): Promise<void> {
|
||||
this.activeWorkers.delete(validReleaseId(releaseId));
|
||||
if (this.pendingExplicitCleanupPluginIds.size === 0) return;
|
||||
await this.withOperation(async () => {
|
||||
for (const pluginId of [...this.pendingExplicitCleanupPluginIds]) {
|
||||
const result = await this.removeUnusedLocked(pluginId, undefined, 'explicit');
|
||||
if (result.status === 'removed') this.pendingExplicitCleanupPluginIds.delete(pluginId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async readInstalledIndex(): Promise<readonly InstalledReleaseRecord[]> {
|
||||
@@ -788,12 +806,13 @@ export class PluginPackageStore {
|
||||
if (item.action === 'keep') {
|
||||
if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release');
|
||||
this.assertBinding(binding);
|
||||
await this.persistSelectedChannel(index, pluginId, current.releaseId, channel, binding);
|
||||
return {
|
||||
status: 'kept',
|
||||
pluginId,
|
||||
releaseId: current.releaseId,
|
||||
version: current.version,
|
||||
...(current.channel === undefined ? {} : { channel: current.channel }),
|
||||
channel,
|
||||
packageRoot: current.packageRoot,
|
||||
definition: current.definition,
|
||||
};
|
||||
@@ -814,6 +833,7 @@ export class PluginPackageStore {
|
||||
const existing = await this.getInstalledFromIndex(index, pluginId, item.releaseId);
|
||||
if (existing && !existing.unavailableReason) {
|
||||
this.assertBinding(binding);
|
||||
await this.persistSelectedChannel(index, pluginId, existing.releaseId, channel, binding);
|
||||
await this.setCurrentSelection(
|
||||
await this.readCurrentSelection(),
|
||||
pluginId,
|
||||
@@ -824,7 +844,7 @@ export class PluginPackageStore {
|
||||
pluginId,
|
||||
releaseId: existing.releaseId,
|
||||
version: existing.version,
|
||||
...(existing.channel === undefined ? {} : { channel: existing.channel }),
|
||||
channel,
|
||||
packageRoot: existing.packageRoot,
|
||||
definition: existing.definition,
|
||||
};
|
||||
@@ -1016,6 +1036,33 @@ export class PluginPackageStore {
|
||||
});
|
||||
}
|
||||
|
||||
private async persistSelectedChannel(
|
||||
index: IndexDocument,
|
||||
pluginId: string,
|
||||
releaseId: string,
|
||||
channel: 'stable' | 'beta',
|
||||
binding: AccountBinding,
|
||||
): Promise<void> {
|
||||
const record = index.releases.find((candidate) => (
|
||||
candidate.pluginId === pluginId && candidate.releaseId === releaseId
|
||||
));
|
||||
if (!record) fail('plugin_store_index_invalid', 'selected Release is missing from the package index');
|
||||
if (record.channel === channel) return;
|
||||
this.assertBinding(binding);
|
||||
const releases = index.releases.map((candidate) => (
|
||||
candidate === record ? Object.freeze({ ...candidate, channel }) : candidate
|
||||
));
|
||||
try {
|
||||
await this.writeIndex(this.indexPath, serializeIndex({
|
||||
schema_version: INDEX_SCHEMA_VERSION,
|
||||
releases,
|
||||
}));
|
||||
} catch {
|
||||
throw new PluginPackageStoreError('plugin_install_failed', 'package channel update failed');
|
||||
}
|
||||
this.assertBinding(binding);
|
||||
}
|
||||
|
||||
private async removeUnusedLocked(
|
||||
validated: string,
|
||||
binding?: AccountBinding,
|
||||
|
||||
@@ -142,7 +142,7 @@ export interface PiManagedWorkerOpenerOptions {
|
||||
userDataDir: string;
|
||||
bundledSkillsDir: string;
|
||||
getSkillRoots?(): readonly string[] | Promise<readonly string[]>;
|
||||
registerActivePluginReleases?(releaseIds: readonly string[]): () => void;
|
||||
registerActivePluginReleases?(releaseIds: readonly string[]): () => void | Promise<void>;
|
||||
loadProviderInput(): Promise<PiManagedProviderInput>;
|
||||
resolveCredential(account: ProviderAccount): Promise<string | null>;
|
||||
getLocalProxyCredential?(): Promise<string | undefined>;
|
||||
@@ -335,7 +335,7 @@ export function createPiManagedWorkerOpener(
|
||||
try {
|
||||
await extension.dispose();
|
||||
} finally {
|
||||
releaseActivePluginReleases?.();
|
||||
await releaseActivePluginReleases?.();
|
||||
}
|
||||
};
|
||||
let unsubscribeExtensionInvalidation = process.subscribeInvalidation(() => {
|
||||
|
||||
Reference in New Issue
Block a user