fix(coding-agent): avoid blocking footer branch refresh and isolate invalid extension provider registrations closes #2418 closes #2431
This commit is contained in:
@@ -141,8 +141,8 @@ export function createExtensionRuntime(): ExtensionRuntime {
|
||||
pendingProviderRegistrations: [],
|
||||
// Pre-bind: queue registrations so bindCore() can flush them once the
|
||||
// model registry is available. bindCore() replaces both with direct calls.
|
||||
registerProvider: (name, config) => {
|
||||
runtime.pendingProviderRegistrations.push({ name, config });
|
||||
registerProvider: (name, config, extensionPath = "<unknown>") => {
|
||||
runtime.pendingProviderRegistrations.push({ name, config, extensionPath });
|
||||
},
|
||||
unregisterProvider: (name) => {
|
||||
runtime.pendingProviderRegistrations = runtime.pendingProviderRegistrations.filter((r) => r.name !== name);
|
||||
@@ -271,11 +271,11 @@ function createExtensionAPI(
|
||||
},
|
||||
|
||||
registerProvider(name: string, config: ProviderConfig) {
|
||||
runtime.registerProvider(name, config);
|
||||
runtime.registerProvider(name, config, extension.path);
|
||||
},
|
||||
|
||||
unregisterProvider(name: string) {
|
||||
runtime.unregisterProvider(name);
|
||||
runtime.unregisterProvider(name, extension.path);
|
||||
},
|
||||
|
||||
events: eventBus,
|
||||
|
||||
@@ -271,11 +271,20 @@ export class ExtensionRunner {
|
||||
this.getSystemPromptFn = contextActions.getSystemPrompt;
|
||||
|
||||
// Flush provider registrations queued during extension loading
|
||||
for (const { name, config } of this.runtime.pendingProviderRegistrations) {
|
||||
if (providerActions?.registerProvider) {
|
||||
providerActions.registerProvider(name, config);
|
||||
} else {
|
||||
this.modelRegistry.registerProvider(name, config);
|
||||
for (const { name, config, extensionPath } of this.runtime.pendingProviderRegistrations) {
|
||||
try {
|
||||
if (providerActions?.registerProvider) {
|
||||
providerActions.registerProvider(name, config);
|
||||
} else {
|
||||
this.modelRegistry.registerProvider(name, config);
|
||||
}
|
||||
} catch (err) {
|
||||
this.emitError({
|
||||
extensionPath,
|
||||
event: "register_provider",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.runtime.pendingProviderRegistrations = [];
|
||||
|
||||
@@ -1307,15 +1307,15 @@ export type SetLabelHandler = (entryId: string, label: string | undefined) => vo
|
||||
export interface ExtensionRuntimeState {
|
||||
flagValues: Map<string, boolean | string>;
|
||||
/** Provider registrations queued during extension loading, processed when runner binds */
|
||||
pendingProviderRegistrations: Array<{ name: string; config: ProviderConfig }>;
|
||||
pendingProviderRegistrations: Array<{ name: string; config: ProviderConfig; extensionPath: string }>;
|
||||
/**
|
||||
* Register or unregister a provider.
|
||||
*
|
||||
* Before bindCore(): queues registrations / removes from queue.
|
||||
* After bindCore(): calls ModelRegistry directly for immediate effect.
|
||||
*/
|
||||
registerProvider: (name: string, config: ProviderConfig) => void;
|
||||
unregisterProvider: (name: string) => void;
|
||||
registerProvider: (name: string, config: ProviderConfig, extensionPath?: string) => void;
|
||||
unregisterProvider: (name: string, extensionPath?: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawnSync } from "child_process";
|
||||
import { type ExecFileException, execFile, spawnSync } from "child_process";
|
||||
import { existsSync, type FSWatcher, readFileSync, statSync, watch } from "fs";
|
||||
import { dirname, join, resolve } from "path";
|
||||
|
||||
@@ -47,7 +47,7 @@ function findGitPaths(): GitPaths | null {
|
||||
}
|
||||
|
||||
/** Ask git for the current branch. Returns null on detached HEAD or if git is unavailable. */
|
||||
function resolveBranchWithGit(repoDir: string): string | null {
|
||||
function resolveBranchWithGitSync(repoDir: string): string | null {
|
||||
const result = spawnSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
@@ -57,11 +57,35 @@ function resolveBranchWithGit(repoDir: string): string | null {
|
||||
return branch || null;
|
||||
}
|
||||
|
||||
/** Ask git for the current branch asynchronously. Returns null on detached HEAD or if git is unavailable. */
|
||||
function resolveBranchWithGitAsync(repoDir: string): Promise<string | null> {
|
||||
return new Promise((resolvePromise) => {
|
||||
execFile(
|
||||
"git",
|
||||
["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"],
|
||||
{
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
},
|
||||
(error: ExecFileException | null, stdout: string) => {
|
||||
if (error) {
|
||||
resolvePromise(null);
|
||||
return;
|
||||
}
|
||||
const branch = stdout.trim();
|
||||
resolvePromise(branch || null);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides git branch and extension statuses - data not otherwise accessible to extensions.
|
||||
* Token stats, model info available via ctx.sessionManager and ctx.model.
|
||||
*/
|
||||
export class FooterDataProvider {
|
||||
private static readonly WATCH_DEBOUNCE_MS = 500;
|
||||
|
||||
private extensionStatuses = new Map<string, string>();
|
||||
private cachedBranch: string | null | undefined = undefined;
|
||||
private gitPaths: GitPaths | null | undefined = undefined;
|
||||
@@ -69,6 +93,10 @@ export class FooterDataProvider {
|
||||
private reftableWatcher: FSWatcher | null = null;
|
||||
private branchChangeCallbacks = new Set<() => void>();
|
||||
private availableProviderCount = 0;
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private refreshInFlight = false;
|
||||
private refreshPending = false;
|
||||
private disposed = false;
|
||||
|
||||
constructor() {
|
||||
this.gitPaths = findGitPaths();
|
||||
@@ -78,7 +106,7 @@ export class FooterDataProvider {
|
||||
/** Current git branch, null if not in repo, "detached" if detached HEAD */
|
||||
getGitBranch(): string | null {
|
||||
if (this.cachedBranch === undefined) {
|
||||
this.cachedBranch = this.resolveGitBranch();
|
||||
this.cachedBranch = this.resolveGitBranchSync();
|
||||
}
|
||||
return this.cachedBranch;
|
||||
}
|
||||
@@ -120,6 +148,11 @@ export class FooterDataProvider {
|
||||
|
||||
/** Internal: cleanup */
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
if (this.headWatcher) {
|
||||
this.headWatcher.close();
|
||||
this.headWatcher = null;
|
||||
@@ -135,23 +168,66 @@ export class FooterDataProvider {
|
||||
for (const cb of this.branchChangeCallbacks) cb();
|
||||
}
|
||||
|
||||
private refreshGitBranch(): void {
|
||||
const nextBranch = this.resolveGitBranch();
|
||||
if (this.cachedBranch !== undefined && this.cachedBranch !== nextBranch) {
|
||||
this.cachedBranch = nextBranch;
|
||||
this.notifyBranchChange();
|
||||
return;
|
||||
private scheduleRefresh(): void {
|
||||
if (this.disposed) return;
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
}
|
||||
this.cachedBranch = nextBranch;
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.refreshTimer = null;
|
||||
void this.refreshGitBranchAsync();
|
||||
}, FooterDataProvider.WATCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private resolveGitBranch(): string | null {
|
||||
private async refreshGitBranchAsync(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
if (this.refreshInFlight) {
|
||||
this.refreshPending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshInFlight = true;
|
||||
try {
|
||||
const nextBranch = await this.resolveGitBranchAsync();
|
||||
if (this.disposed) return;
|
||||
if (this.cachedBranch !== undefined && this.cachedBranch !== nextBranch) {
|
||||
this.cachedBranch = nextBranch;
|
||||
this.notifyBranchChange();
|
||||
return;
|
||||
}
|
||||
this.cachedBranch = nextBranch;
|
||||
} finally {
|
||||
this.refreshInFlight = false;
|
||||
if (this.refreshPending && !this.disposed) {
|
||||
this.refreshPending = false;
|
||||
this.scheduleRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveGitBranchSync(): string | null {
|
||||
try {
|
||||
if (!this.gitPaths) return null;
|
||||
const content = readFileSync(this.gitPaths.headPath, "utf8").trim();
|
||||
if (content.startsWith("ref: refs/heads/")) {
|
||||
const branch = content.slice(16);
|
||||
return branch === ".invalid" ? (resolveBranchWithGit(this.gitPaths.repoDir) ?? "detached") : branch;
|
||||
return branch === ".invalid" ? (resolveBranchWithGitSync(this.gitPaths.repoDir) ?? "detached") : branch;
|
||||
}
|
||||
return "detached";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveGitBranchAsync(): Promise<string | null> {
|
||||
try {
|
||||
if (!this.gitPaths) return null;
|
||||
const content = readFileSync(this.gitPaths.headPath, "utf8").trim();
|
||||
if (content.startsWith("ref: refs/heads/")) {
|
||||
const branch = content.slice(16);
|
||||
return branch === ".invalid"
|
||||
? ((await resolveBranchWithGitAsync(this.gitPaths.repoDir)) ?? "detached")
|
||||
: branch;
|
||||
}
|
||||
return "detached";
|
||||
} catch {
|
||||
@@ -168,7 +244,7 @@ export class FooterDataProvider {
|
||||
try {
|
||||
this.headWatcher = watch(dirname(this.gitPaths.headPath), (_eventType, filename) => {
|
||||
if (!filename || filename.toString() === "HEAD") {
|
||||
this.refreshGitBranch();
|
||||
this.scheduleRefresh();
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
@@ -181,7 +257,7 @@ export class FooterDataProvider {
|
||||
if (existsSync(reftableDir)) {
|
||||
try {
|
||||
this.reftableWatcher = watch(reftableDir, () => {
|
||||
this.refreshGitBranch();
|
||||
this.scheduleRefresh();
|
||||
});
|
||||
} catch {
|
||||
// Silently fail if we can't watch
|
||||
|
||||
@@ -569,8 +569,9 @@ export class ModelRegistry {
|
||||
* If provider has oauth: registers OAuth provider for /login support.
|
||||
*/
|
||||
registerProvider(providerName: string, config: ProviderConfigInput): void {
|
||||
this.registeredProviders.set(providerName, config);
|
||||
this.validateProviderConfig(providerName, config);
|
||||
this.applyProviderConfig(providerName, config);
|
||||
this.registeredProviders.set(providerName, config);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -589,6 +590,30 @@ export class ModelRegistry {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private validateProviderConfig(providerName: string, config: ProviderConfigInput): void {
|
||||
if (config.streamSimple && !config.api) {
|
||||
throw new Error(`Provider ${providerName}: "api" is required when registering streamSimple.`);
|
||||
}
|
||||
|
||||
if (!config.models || config.models.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.baseUrl) {
|
||||
throw new Error(`Provider ${providerName}: "baseUrl" is required when defining models.`);
|
||||
}
|
||||
if (!config.apiKey && !config.oauth) {
|
||||
throw new Error(`Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`);
|
||||
}
|
||||
|
||||
for (const modelDef of config.models) {
|
||||
const api = modelDef.api || config.api;
|
||||
if (!api) {
|
||||
throw new Error(`Provider ${providerName}, model ${modelDef.id}: no "api" specified.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyProviderConfig(providerName: string, config: ProviderConfigInput): void {
|
||||
// Register OAuth provider if provided
|
||||
if (config.oauth) {
|
||||
@@ -601,13 +626,10 @@ export class ModelRegistry {
|
||||
}
|
||||
|
||||
if (config.streamSimple) {
|
||||
if (!config.api) {
|
||||
throw new Error(`Provider ${providerName}: "api" is required when registering streamSimple.`);
|
||||
}
|
||||
const streamSimple = config.streamSimple;
|
||||
registerApiProvider(
|
||||
{
|
||||
api: config.api,
|
||||
api: config.api!,
|
||||
stream: (model, context, options) => streamSimple(model, context, options as SimpleStreamOptions),
|
||||
streamSimple,
|
||||
},
|
||||
@@ -624,20 +646,9 @@ export class ModelRegistry {
|
||||
// Full replacement: remove existing models for this provider
|
||||
this.models = this.models.filter((m) => m.provider !== providerName);
|
||||
|
||||
// Validate required fields
|
||||
if (!config.baseUrl) {
|
||||
throw new Error(`Provider ${providerName}: "baseUrl" is required when defining models.`);
|
||||
}
|
||||
if (!config.apiKey && !config.oauth) {
|
||||
throw new Error(`Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`);
|
||||
}
|
||||
|
||||
// Parse and add new models
|
||||
for (const modelDef of config.models) {
|
||||
const api = modelDef.api || config.api;
|
||||
if (!api) {
|
||||
throw new Error(`Provider ${providerName}, model ${modelDef.id}: no "api" specified.`);
|
||||
}
|
||||
|
||||
// Merge headers
|
||||
const providerHeaders = resolveHeaders(config.headers);
|
||||
@@ -657,7 +668,7 @@ export class ModelRegistry {
|
||||
name: modelDef.name,
|
||||
api: api as Api,
|
||||
provider: providerName,
|
||||
baseUrl: config.baseUrl,
|
||||
baseUrl: config.baseUrl!,
|
||||
reasoning: modelDef.reasoning,
|
||||
input: modelDef.input as ("text" | "image")[],
|
||||
cost: modelDef.cost,
|
||||
|
||||
Reference in New Issue
Block a user