handle permanent file plugin errors (#27344)

This commit is contained in:
Sebastian
2026-05-20 18:47:30 +02:00
committed by GitHub
parent 14e9e5d9d6
commit ef82426e28
3 changed files with 255 additions and 26 deletions

View File

@@ -56,6 +56,22 @@ export namespace PluginLoader {
) => void
}
type AttemptResult<R> = {
value?: R
retry: boolean
}
function errorMessage(error: unknown) {
if (!error || typeof error !== "object") return ""
const message = "message" in error && typeof error.message === "string" ? error.message : ""
return message
}
function isRetryableResolveError(stage: "install" | "entry" | "compatibility", error: unknown) {
if (stage !== "install") return false
return errorMessage(error).includes("missing package.json or index file")
}
// Normalize a config item into the loader's internal representation.
function plan(item: ConfigPlugin.Spec): Plan {
const spec = ConfigPlugin.pluginSpecifier(item)
@@ -136,11 +152,12 @@ export namespace PluginLoader {
finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
report: Report | undefined,
): Promise<R | undefined> {
): Promise<AttemptResult<R>> {
const plan = candidate.plan
const filePlugin = pluginSource(plan.spec) === "file"
// Deprecated plugin packages are silently ignored because they are now built in.
if (plan.deprecated) return
if (plan.deprecated) return { retry: false }
report?.start?.(candidate, retry)
@@ -151,25 +168,26 @@ export namespace PluginLoader {
// for example to load theme files from a tui plugin package that has no code entrypoint.
if (missing) {
const value = await missing(resolved.value, candidate.origin, retry)
if (value !== undefined) return value
if (value !== undefined) return { value, retry: false }
}
report?.missing?.(candidate, retry, resolved.value.message, resolved.value)
return
return { retry: false }
}
report?.error?.(candidate, retry, resolved.stage, resolved.error)
return
return { retry: filePlugin && isRetryableResolveError(resolved.stage, resolved.error) }
}
const loaded = await load(resolved.value)
if (!loaded.ok) {
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
return
return { retry: false }
}
// The default behavior is to return the successfully loaded plugin as-is, but callers can
// provide a finisher to adapt the result into a more specific runtime shape.
if (!finish) return loaded.value as R
return finish(loaded.value, candidate.origin, retry)
if (!finish) return { value: loaded.value as R, retry: false }
const value = await finish(loaded.value, candidate.origin, retry)
return { value, retry: false }
}
type Input<R> = {
@@ -183,12 +201,12 @@ export namespace PluginLoader {
// Resolve and load all configured plugins in parallel.
//
// If `wait` is provided, file-based plugins that initially failed are retried once after the
// caller finishes preparing dependencies. This supports local plugins that depend on an install
// step happening elsewhere before their entrypoint becomes loadable.
// If `wait` is provided, file-based plugins with retryable pre-import setup failures are retried
// once after the caller finishes preparing dependencies. Once dynamic import runs, failures are
// treated as permanent for this process because Bun caches failed module resolution.
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
const list: Array<Promise<R | undefined>> = []
const list: Array<Promise<AttemptResult<R>>> = []
for (const candidate of candidates) {
list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report))
}
@@ -196,10 +214,12 @@ export namespace PluginLoader {
if (input.wait) {
let deps: Promise<void> | undefined
for (let i = 0; i < candidates.length; i++) {
if (out[i] !== undefined) continue
const previous = out[i]
if (previous?.value !== undefined) continue
if (previous?.retry !== true) continue
// Only local file plugins are retried. npm plugins already attempted installation during
// the first pass, while file plugins may need the caller's dependency preparation to finish.
// Only pre-import file plugin setup failures are retried. Bun caches failed dynamic imports,
// so dependency waiting cannot fix load/build/runtime/shape failures in this process.
const candidate = candidates[i]
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
deps ??= input.wait()
@@ -210,7 +230,7 @@ export namespace PluginLoader {
// Drop skipped/failed entries while preserving the successful result order.
const ready: R[] = []
for (const item of out) if (item !== undefined) ready.push(item)
for (const item of out) if (item.value !== undefined) ready.push(item.value)
return ready
}
}