This commit is contained in:
Sebastian Herrlinger
2026-05-19 19:01:13 +02:00
parent 08b4e80e54
commit 8a59cbeb98
3 changed files with 212 additions and 23 deletions

View File

@@ -61,12 +61,15 @@ export namespace PluginLoader {
retry: boolean
}
function isRetryableLoadError(error: unknown) {
if (!error || typeof error !== "object") return false
const name = "name" in error && typeof error.name === "string" ? error.name : ""
if (name === "ResolveMessage") return true
function errorMessage(error: unknown) {
if (!error || typeof error !== "object") return ""
const message = "message" in error && typeof error.message === "string" ? error.message : ""
return /Cannot find (?:package|module)|Could not resolve/.test(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.
@@ -171,20 +174,20 @@ export namespace PluginLoader {
return { retry: false }
}
report?.error?.(candidate, retry, resolved.stage, resolved.error)
return { retry: filePlugin }
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 { retry: isRetryableLoadError(loaded.error) }
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 { value: loaded.value as R, retry: false }
const value = await finish(loaded.value, candidate.origin, retry)
return { value, retry: filePlugin && value === undefined }
return { value, retry: false }
}
type Input<R> = {
@@ -198,9 +201,9 @@ 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<AttemptResult<R>>> = []
@@ -215,8 +218,8 @@ export namespace PluginLoader {
if (previous?.value !== undefined) continue
if (previous?.retry !== true) continue
// Only local file plugins with module-resolution failures are retried. Syntax/build errors
// cannot be fixed by dependency installation and should not block TUI startup.
// 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()

View File

@@ -26,6 +26,87 @@ test("does not retry permanent file plugin load errors", async () => {
},
})
let waited = false
const calls: Array<["start" | "error", boolean, string?]> = []
const plugins = await PluginLoader.loadExternal({
items: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
kind: "tui",
wait: async () => {
waited = true
},
report: {
start(_candidate, retry) {
calls.push(["start", retry])
},
error(_candidate, retry, stage) {
calls.push(["error", retry, stage])
},
},
})
expect(plugins).toEqual([])
expect(waited).toBe(false)
expect(calls).toEqual([
["start", false],
["error", false, "load"],
])
})
test("does not retry file plugin load errors caused by missing modules", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "missing-dependency-plugin.ts")
const dep = path.join(dir, "dep.ts")
await Bun.write(
file,
`import value from "./dep"
export default { id: "demo.retry.load", tui: async () => {}, value }
`,
)
return { spec: pathToFileURL(file).href, dep }
},
})
let waited = false
const calls: Array<["start" | "error", boolean, string?]> = []
const plugins = await PluginLoader.loadExternal({
items: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
kind: "tui",
wait: async () => {
waited = true
await Bun.write(tmp.extra.dep, `export default "ready"\n`)
},
finish: async (loaded, _origin, retry) => ({
retry,
value: (loaded.mod.default as { value: string }).value,
}),
report: {
start(_candidate, retry) {
calls.push(["start", retry])
},
error(_candidate, retry, stage) {
calls.push(["error", retry, stage])
},
},
})
expect(waited).toBe(false)
expect(calls).toEqual([
["start", false],
["error", false, "load"],
])
expect(plugins).toEqual([])
})
test("does not retry top-level plugin errors that look like resolver messages", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "throwing-plugin.ts")
await Bun.write(file, `throw new Error("Cannot find package intentional")\n`)
return { spec: pathToFileURL(file).href }
},
})
let waited = false
const plugins = await PluginLoader.loadExternal({
items: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
@@ -582,6 +663,71 @@ test("continues loading when a plugin is missing config metadata", async () => {
}
})
test("does not wait on permanent tui plugin startup failures", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const binary = path.join(dir, "binary-plugin")
const invalidShape = path.join(dir, "invalid-shape-plugin.ts")
const missingID = path.join(dir, "missing-id-plugin.ts")
const good = path.join(dir, "good-plugin.ts")
const marker = path.join(dir, "good-called.txt")
await Bun.write(binary, new Uint8Array([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01]))
await Bun.write(invalidShape, `export default { id: "demo.invalid.shape" }\n`)
await Bun.write(missingID, `export default { tui: async () => {} }\n`)
await Bun.write(
good,
`export default {
id: "demo.good.after-bad",
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return {
binarySpec: pathToFileURL(binary).href,
invalidShapeSpec: pathToFileURL(invalidShape).href,
missingIDSpec: pathToFileURL(missingID).href,
goodSpec: pathToFileURL(good).href,
marker,
}
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({
api: createTuiPluginApi(),
config: createTuiResolvedConfig({
plugin: [tmp.extra.binarySpec, tmp.extra.invalidShapeSpec, tmp.extra.missingIDSpec, tmp.extra.goodSpec],
plugin_origins: [
{ spec: tmp.extra.binarySpec, scope: "local", source: path.join(tmp.path, "tui.json") },
{ spec: tmp.extra.invalidShapeSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
{ spec: tmp.extra.missingIDSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
{ spec: tmp.extra.goodSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
],
}),
})
expect(wait).toHaveBeenCalledTimes(0)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.good.after-bad")?.active).toBe(true)
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.binarySpec)).toBe(false)
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.invalidShapeSpec)).toBe(false)
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.missingIDSpec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("initializes external tui plugins in config order", async () => {
const globalJson = path.join(Global.Path.config, "tui.json")
const globalJsonc = path.join(Global.Path.config, "tui.jsonc")

View File

@@ -1180,7 +1180,52 @@ export default {
),
)
it.live("retries file plugins when finish returns undefined", () =>
it.live("does not retry permanent file plugin entry errors", () =>
withTmp(
async (dir) => {
const mod = path.join(dir, "bad-entry")
const spec = pathToFileURL(mod).href
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({ exports: { "./tui": "../outside.js" } }, null, 2),
)
return { spec }
},
(tmp) =>
Effect.gen(function* () {
let wait = 0
const errors: Array<[string, boolean]> = []
const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({
items: [
{
spec: tmp.extra.spec,
scope: "local" as const,
source: tmp.path,
},
],
kind: "tui",
wait: async () => {
wait += 1
},
report: {
error(_candidate, retry, stage) {
errors.push([stage, retry])
},
},
}),
)
expect(loaded).toEqual([])
expect(wait).toBe(0)
expect(errors).toEqual([["entry", false]])
}),
),
)
it.live("does not retry file plugins when finish returns undefined", () =>
withTmp(
async (dir) => {
const file = path.join(dir, "plugin.ts")
@@ -1206,20 +1251,15 @@ export default {
wait: async () => {
wait += 1
},
finish: async (load, _item, retry) => {
finish: async () => {
count += 1
if (!retry) return
return {
retry,
spec: load.spec,
}
},
}),
)
expect(wait).toBe(1)
expect(count).toBe(2)
expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }])
expect(wait).toBe(0)
expect(count).toBe(1)
expect(loaded).toEqual([])
}),
),
)