Files
opencode/packages/core/test/plugin-failure.test.ts
T

306 lines
12 KiB
TypeScript

import { expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Schema } from "effect"
import { Bus } from "@opencode/core/bus"
import { Command } from "@opencode/core/command"
import { Plugin } from "@opencode/core/plugin"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { Rpc } from "@opencode/core/rpc"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
it.live("removes a failed plugin's hooks and RPC handlers without affecting healthy plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const hooks = yield* PluginHooks.Service
const rpc = yield* Rpc.Service
const cleaned = yield* Deferred.make<void>()
const invoked: string[] = []
let fail = false
yield* plugins.activate(
["broken", "healthy"].map((id) => ({
id,
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
// Finalizers run in reverse order, so this signals after registration cleanup.
if (id === "broken") yield* Effect.addFinalizer(() => Deferred.succeed(cleaned, undefined))
yield* ctx.command.transform((editor) => {
editor.add({ name: id, execute: () => Effect.void })
if (id === "broken" && fail) throw new Error("transform failed")
})
yield* ctx.shell.hook("create.before", () => Effect.sync(() => void invoked.push(id)))
yield* ctx.rpc
.register(
Rpc.define({ id, methods: { check: { input: Schema.Struct({}), output: Schema.String } }, events: {} }),
{ check: () => Effect.succeed(id) },
)
.pipe(Effect.orDie)
if (id === "broken") yield* Effect.addFinalizer(() => plugins.awaitActivation)
}),
})),
)
const trigger = hooks.trigger("shell", "create.before", {
command: "echo fixture",
cwd: ".",
timeout: 1_000,
shell: "sh",
env: {},
})
yield* trigger
expect(invoked).toEqual(["broken", "healthy"])
expect(yield* rpc.call("broken", "check", {})).toBe("broken")
expect(yield* rpc.call("healthy", "check", {})).toBe("healthy")
expect((yield* commands.list()).map((command) => command.name)).toEqual(["broken", "healthy"])
fail = true
yield* commands.reload()
yield* Deferred.await(cleaned).pipe(Effect.timeout("1 second"))
invoked.length = 0
yield* trigger
expect(invoked).toEqual(["healthy"])
expect(yield* rpc.call("broken", "check", {}).pipe(Effect.flip)).toMatchObject({ type: "rpc.unavailable" })
expect(yield* rpc.call("healthy", "check", {})).toBe("healthy")
expect((yield* commands.list()).map((command) => command.name)).toEqual(["healthy"])
expect((yield* plugins.list()).map((plugin) => plugin.state.status)).toEqual(["failed", "active"])
}),
)
Array.of("reload", "teardown").forEach((boundary) =>
it.live(`does not join queued failed-plugin cleanup during ${boundary}`, () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const entered = yield* Deferred.make<void>()
const escape = yield* Deferred.make<void>()
const cleaned = yield* Deferred.make<void>()
let fail = false
yield* plugins.activate([
{
id: "changing",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.command.transform((editor) => {
editor.add({ name: "changing", description: "old", execute: () => Effect.void })
if (fail) throw new Error("changing failed")
})
yield* Effect.addFinalizer(() =>
Deferred.succeed(entered, undefined).pipe(
Effect.andThen(plugins.awaitActivation.pipe(Effect.raceFirst(Deferred.await(escape)))),
Effect.andThen(Deferred.succeed(cleaned, undefined)),
),
)
}),
},
{
id: "trigger",
revision: "1",
effect: () =>
Effect.addFinalizer(() =>
boundary === "teardown"
? commands.reload().pipe(Effect.andThen(commands.list()), Effect.asVoid)
: Effect.void,
),
},
])
fail = true
const activation = yield* (boundary === "reload" ? commands.reload() : Effect.void).pipe(
Effect.andThen(
plugins.activate([
{
id: "changing",
revision: "2",
effect: (ctx) =>
ctx.command
.transform((editor) =>
editor.add({ name: "changing", description: "new", execute: () => Effect.void }),
)
.pipe(Effect.asVoid),
},
]),
),
Effect.forkChild({ startImmediately: true }),
)
yield* Deferred.await(entered)
const result = yield* Fiber.join(activation).pipe(Effect.timeout("250 millis"), Effect.exit)
// Allow teardown to finish even if activation incorrectly joins the old finalizer.
yield* Deferred.succeed(escape, undefined)
yield* Fiber.join(activation)
yield* plugins.awaitActivation
yield* Deferred.await(cleaned)
expect(Exit.isSuccess(result)).toBe(true)
expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" })
expect(yield* commands.get("changing")).toMatchObject({ description: "new" })
}),
),
)
it.live("does not restore a disabled generation with a pending failure when its replacement fails setup", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const loads: string[] = []
let fail = false
const generation = (revision: string): Plugin.Generation => ({
id: "replacement",
revision,
effect: (ctx) =>
Effect.gen(function* () {
loads.push(revision)
if (revision === "2") yield* Effect.die("setup failed")
yield* ctx.command.transform((editor) => {
editor.add({ name: "replacement", execute: () => Effect.void })
if (fail) throw new Error("replay failed")
})
}),
})
yield* plugins.activate([generation("1")])
fail = true
yield* commands.reload()
yield* plugins.activate([generation("2")])
yield* plugins.awaitActivation
expect(loads).toEqual(["1", "2"])
expect((yield* plugins.list())[0]?.state.status).toBe("failed")
expect(yield* commands.get("replacement")).toBeUndefined()
}),
)
Array.of("pending", "reported").forEach((status) =>
it.live(`preserves ${status} failures when an earlier plugin changes`, () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const loads: string[] = []
let fail = true
const generation = (id: string, revision: string): Plugin.Generation => ({
id,
revision,
effect: (ctx) =>
Effect.gen(function* () {
loads.push(`${id}@${revision}`)
yield* ctx.command.transform((editor) => {
editor.add({ name: id, execute: () => Effect.void })
if (id === "broken" && fail) throw new Error("broken failed")
})
}),
})
const broken = generation("broken", "1")
const later = generation("later", "1")
yield* plugins.activate([generation("earlier", "1"), broken, later])
if (status === "reported") yield* plugins.awaitActivation
fail = false
yield* plugins.activate([generation("earlier", "2"), broken, later])
yield* plugins.awaitActivation
expect(loads).toEqual(["earlier@1", "broken@1", "later@1", "earlier@2", "later@1"])
const failed = (yield* plugins.list())[1]?.state
expect(failed).toMatchObject({ status: "failed", ref: expect.stringMatching(/^err_/) })
expect((yield* commands.list()).map((entry) => entry.name)).toEqual(["earlier", "later"])
// Reordering and removing other plugins must preserve the same failure too.
yield* plugins.activate([later, broken, generation("earlier", "2")])
yield* plugins.awaitActivation
expect((yield* plugins.list())[1]?.state).toEqual(failed)
expect((yield* commands.list()).map((entry) => entry.name)).toEqual(["later", "earlier"])
yield* plugins.activate([broken, later])
yield* plugins.awaitActivation
expect((yield* plugins.list())[0]?.state).toEqual(failed)
expect(loads.filter((entry) => entry === "broken@1")).toHaveLength(1)
yield* plugins.activate([generation("broken", "2"), later])
yield* plugins.awaitActivation
expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" })
expect(yield* commands.get("broken")).toBeDefined()
expect(loads.filter((entry) => entry === "broken@2")).toHaveLength(1)
}),
),
)
it.live("continues failure reporting and cleanup after a plugin update observer fails", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const bus = yield* Bus.Service
const cleaned: string[] = []
let fail = false
let failPublication = true
yield* plugins.activate(
["first", "second"].map((id) => ({
id,
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.sync(() => void cleaned.push(id)))
yield* ctx.command.transform((editor) => {
editor.add({ name: id, execute: () => Effect.void })
if (fail) throw new Error(`${id} failed`)
})
}),
})),
)
yield* Effect.acquireRelease(
bus.listen((event) => {
if (event.type !== Plugin.Event.Updated.type || !failPublication) return Effect.void
failPublication = false
return Effect.die("observer failed")
}),
(unsubscribe) => unsubscribe,
)
fail = true
yield* commands.reload()
const ready = yield* plugins.awaitActivation.pipe(Effect.timeout("250 millis"), Effect.exit)
expect(Exit.isSuccess(ready)).toBe(true)
expect((yield* plugins.list()).map((entry) => entry.state.status)).toEqual(["failed", "failed"])
expect(cleaned.toSorted()).toEqual(["first", "second"])
expect(yield* commands.list()).toEqual([])
}),
)
it.live("settles readiness before shutdown joins a disabled plugin's finalizers", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const entered = yield* Deferred.make<void>()
const escape = yield* Deferred.make<void>()
let fail = false
yield* plugins.activate([
{
id: "closing",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.command.transform((editor) => {
editor.add({ name: "closing", execute: () => Effect.void })
if (fail) throw new Error("closing failed")
})
yield* Effect.addFinalizer(() =>
Deferred.succeed(entered, undefined).pipe(
Effect.andThen(plugins.awaitActivation.pipe(Effect.raceFirst(Deferred.await(escape)))),
),
)
}),
},
])
fail = true
const shutdown = yield* commands
.reload()
.pipe(Effect.andThen(plugins.close(Exit.void)), Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(entered)
const result = yield* Fiber.join(shutdown).pipe(Effect.timeout("250 millis"), Effect.exit)
// Release the fixture even on the old implementation, rather than hanging test teardown.
yield* Deferred.succeed(escape, undefined)
yield* Fiber.join(shutdown)
expect(Exit.isSuccess(result)).toBe(true)
const release = yield* plugins.hold()
yield* plugins.awaitActivation
let restarted = false
yield* plugins.activate([
{ id: "after-close", revision: "1", effect: () => Effect.sync(() => void (restarted = true)) },
])
yield* release
expect(restarted).toBe(false)
}),
)