Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions packages/opencode/src/cli/cmd/run-events.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Option } from "effect"
import { Cause, Effect, Fiber, Option } from "effect"
import { Bus } from "@/bus"
import { Permission } from "@/permission"
import { Question } from "@/question"
Expand Down Expand Up @@ -86,8 +86,44 @@ export const make = Effect.fn("RunEvents.make")(function* (config: Config) {
}
}

// bus.subscribeCallback wraps the callback in an Effect.tryPromise-based
// subscription handler, so a Promise-returning callback (like Effect.runPromise)
// serializes handler completion per subscription. runFork returns a Fiber
// synchronously (non-thenable), unblocking dispatch so descendant question/
// permission events are processed concurrently — important for long-running
// subagent loops with many simultaneous descendants. Defects inside the forked
// fiber do not surface through that subscription callback wrapper, so log them
// here instead. Track in-flight fibers so unsubscribe() can interrupt them and
// bound handler work to the RunEvents lifecycle.
const inflight = new Set<Fiber.Fiber<void>>()
let closed = false
const fork = (effect: Effect.Effect<void>) => {
if (closed) {
// unsubscribe() already ran but bus subscription teardown is async, so
// a late callback can still reach fork(). Skip starting the handler
// entirely so no side effects (bump, reject, reply) leak past teardown.
// Returning undefined (not a Promise) still unblocks the bus dispatch
// wrapper without spawning a no-op fiber.
return
}
const fiber = Effect.runFork(
effect.pipe(
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.sync(() => log.error("handler failed", { cause })),
),
),
Comment on lines +110 to +116
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect.tapCause will also run when the forked fiber is interrupted (e.g. via unsubscribe()), so normal shutdown will be logged as handler failed. Consider filtering out interrupt-only causes (e.g. Cause.hasInterruptsOnly(cause)) so only real handler failures/defects are logged.

Copilot uses AI. Check for mistakes.
)
inflight.add(fiber)
// Register cleanup outside the forked effect to avoid a TDZ/race between
// synchronous fiber completion and inflight.add — Fiber.await observes
// completion regardless of how fast the fiber runs.
Effect.runFork(Fiber.await(fiber).pipe(Effect.ensuring(Effect.sync(() => inflight.delete(fiber)))))
}

const unsubQuestion = yield* bus.subscribeCallback(Question.Event.Asked, (evt) =>
Effect.runPromise(
fork(
Effect.gen(function* () {
const mine = yield* isDescendant(evt.properties.sessionID)
if (!mine) return
Expand All @@ -98,7 +134,7 @@ export const make = Effect.fn("RunEvents.make")(function* (config: Config) {
)

const unsubPermission = yield* bus.subscribeCallback(Permission.Event.Asked, (evt) =>
Effect.runPromise(
fork(
Effect.gen(function* () {
const mine = yield* isDescendant(evt.properties.sessionID)
if (!mine) return
Expand All @@ -113,8 +149,13 @@ export const make = Effect.fn("RunEvents.make")(function* (config: Config) {
)

const unsubscribe = () => {
closed = true
unsubQuestion()
unsubPermission()
inflight.forEach((fiber) => Effect.runFork(Fiber.interrupt(fiber)))
// Don't clear() — let the per-fiber Fiber.await observers remove entries
// as their interrupts settle, so any stragglers caught by the closed-flag
// branch above still get cleaned up correctly.
}
Comment on lines 89 to 159
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change introduces new lifecycle behavior (forking handlers + tracking/interrupting inflight fibers + logging via tapCause) but the existing tests don’t appear to assert that unsubscribe() actually interrupts long-running in-flight handlers, or that inflight is cleaned up after handler completion. Adding a focused test that publishes many events with an intentionally blocking handler and verifies dispatch is unblocked + fibers are interrupted on unsubscribe would help prevent regressions of the audit finding.

Copilot uses AI. Check for mistakes.

return { stats, unsubscribe } satisfies Handle
Expand Down
46 changes: 46 additions & 0 deletions packages/opencode/test/cli/run-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,4 +523,50 @@ describe("cli/run-events", () => {
}),
),
)

// Lifecycle test for F7 fiber-tracking: after unsubscribe(), late-arriving
// bus callbacks must not produce new auto-rejects. This exercises both the
// bus unsubscription path and the `closed` flag in fork() that prevents
// late callbacks from forking new handler fibers after teardown has begun.
it.live("does not auto-reject question.asked after unsubscribe()", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const question = yield* Question.Service
const rootSessionID = SessionID.make("ses_root_post_unsub_000000000000")
const handler = yield* RunEvents.make({
rootSessionID,
skipPermissions: false,
jsonMode: false,
})
yield* Effect.sync(() => handler.unsubscribe())

// Ask after unsubscribe — without the bus subscription, no auto-reject
// handler runs and the question stays pending.
const fiber = yield* question
.ask({
sessionID: rootSessionID,
questions: [{ question: "post?", header: "h", options: [{ label: "x", description: "x" }] }],
})
.pipe(Effect.forkScoped)

const pending = yield* pollForLength(() => question.list(), 1)
expect(pending[0].sessionID).toBe(rootSessionID)
expect(handler.stats.autoRejectedQuestions).toBe(0)

Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test can pass even if a late bus callback auto-rejects after the initial stats assertion (e.g., unsubscribe teardown is async). To make it reliably assert the intended lifecycle behavior, add a short wait and re-check that the question is still pending and autoRejectedQuestions is still 0 before manually rejecting.

Suggested change
// Give any late async bus callbacks a chance to run, then verify the
// question is still pending and no auto-reject occurred.
yield* Effect.sleep("50 millis")
const stillPending = yield* question.list()
expect(stillPending).toHaveLength(1)
expect(stillPending[0].id).toBe(pending[0].id)
expect(stillPending[0].sessionID).toBe(rootSessionID)
expect(handler.stats.autoRejectedQuestions).toBe(0)

Copilot uses AI. Check for mistakes.
// Give any late async bus callbacks a chance to run, then verify the
// question is still pending and no auto-reject occurred.
yield* Effect.sleep("50 millis")
const stillPending = yield* question.list()
expect(stillPending).toHaveLength(1)
expect(stillPending[0].id).toBe(pending[0].id)
expect(stillPending[0].sessionID).toBe(rootSessionID)
expect(handler.stats.autoRejectedQuestions).toBe(0)

// Manually clear the pending question so the test can finish.
yield* question.reject(pending[0].id)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
}),
),
)
})
Loading