Skip to content

TUI silently drops permission.asked events during project-lookup race → tool hangs forever → OOM #27879

Closed as not planned

Description

@ton618to

Description

The TUI's event subscription filter at packages/opencode/src/cli/cmd/tui/context/event.ts:19 silently drops every server event whose event.project does not match project.project(). The TUI's project.project() is loaded asynchronously via sdk.client.project.current(...) and is undefined until that REST call resolves. The SSE stream (sdk.tsx:74-124 startSSE) is started in parallel from the same onMount, so events published by the server during the cold-start window race the project lookup and are silently dropped.

The most painful instance of this is permission.asked for external_directory. If the user's first prompt asks the model to read a file outside the worktree (very common: looking at sibling repos, or asking the agent to inspect a config under ~), the read tool calls assertExternalDirectoryEffect → ctx.ask({ permission: "external_directory", ... }) → permission.ask (permission/index.ts:161-195). The server publishes Event.Asked and then awaits an in-memory Deferred<void, RejectedError | CorrectedError> with no timeout. If the TUI dropped the asked event, the deferred is never resolved → the read tool hangs forever → and (independent bug) memory keeps growing because the AI SDK's result.fullStream.tee() buffer keeps reading model SSE chunks while opencode's consumer fiber is parked → the agent OOMs.

Symptom from the user's perspective: "I asked the model to read a file, the TUI just sits there with the tool call pending, no permission dialog ever appears, and after a while the process gets OOM-killed."

Reproduction

  1. Open the TUI in a project. (Don't run permission.list() first — that doesn't happen on bootstrap.)
  2. Immediately on first prompt, ask the model to read /some/absolute/path/outside/the/worktree. Best repro: ask in the very first model turn so the SSE event lands during the project-lookup race.
  3. Tool call sits in running/pending state. No <PermissionPrompt> ever renders.
  4. RSS climbs at ~KB-MB/sec while the model continues to stream tokens that opencode can't consume past the parked tool fiber.
  5. Eventually OS kills the process (or with reasoning models, JS heap exhausts in seconds).

You can also reproduce more reliably by inserting a console.log("filter", event.project, project.project()) before the conditional at event.ts:19. You'll see one or more events where project.project() is undefined while event.project is a real id — those are the dropped ones.

Root cause walkthrough

// packages/opencode/src/cli/cmd/tui/context/event.ts:13-22
function subscribe(handler) {
  return sdk.event.on("event", (event) => {
    if (event.payload.type === "sync") return
    if (event.directory === "global" || event.project === project.project()) {
      handler(event.payload, { workspace: event.workspace })
    }
    // else: silently dropped
  })
}
// packages/opencode/src/bus/index.ts:101-106 — server always tags events with the real project id
GlobalBus.emit("event", {
  directory: dir,
  project: context.project.id,   // never undefined
  workspace,
  payload,
})
// packages/opencode/src/cli/cmd/tui/context/project.tsx:36-47
async function sync() {
  const workspace = store.workspace.current
  const [path, project] = await Promise.all([
    sdk.client.path.get({ workspace }),
    sdk.client.project.current({ workspace }),
  ])
  batch(() => {
    setStore("instance", "path", reconcile(path.data || defaultPath))
    setStore("project", "id", project.data?.id)   // <- undefined until this resolves
  })
}

startSSE (sdk.tsx:74-124) and bootstrap → project.sync() (sync.tsx:378-479) both run from onMount. There is no ordering guarantee. Anything the server publishes between SSE attach and project resolution is filtered out by event.ts:19.

Why it doesn't recover

packages/opencode/src/cli/cmd/tui/context/sync.tsx bootstrap hydrates provider, agent, sessions, todos, mcp, formatter, status, lsp, command — but never calls sdk.client.permission.list(). The REST endpoint exists (server/routes/instance/httpapi/handlers/permission.ts), but the TUI relies entirely on the SSE stream. Once an event is dropped, the TUI cannot rebuild "what is currently pending" from the server's in-memory pending map.

Why it OOMs

Independent contributing bug, worth mentioning so the symptoms add up:

  • permission.ask at permission/index.ts:191 does Deferred.await(deferred) with no timeout.
  • While the read tool's Effect fiber is parked on that deferred, ai-sdk's streamText continues to drain the model SSE because result.fullStream is implemented via baseStream.tee() (ai@6.0.168 dist/index.mjs:7670-7697). tee() has no shared backpressure — the unread branch buffers every chunk without bound.
  • bus/index.ts:54,80 uses PubSub.unbounded; per-delta bus.publish(MessageV2.Event.PartDelta) (session/processor.ts:580-588) fans out to every slow subscriber's queue without bound.

So a single dropped permission event compounds into a full OOM.

Suggested fixes

In order of "smallest defensible change first":

A. Stop dropping events when project ID isn't loaded yet (1-line fix, smallest change):

// event.ts:19
const currentProject = project.project()
if (
  event.directory === "global" ||
  currentProject === undefined ||           // accept until project resolves
  event.project === currentProject
) {
  handler(event.payload, { workspace: event.workspace })
}

Trade-off: in attached / multi-project server scenarios the TUI may briefly receive cross-project events during the cold-start window. They re-tighten the moment project.project() resolves. This matches the existing fall-through for directory === "global" events that already cross the boundary.

B. Seed pending permissions on bootstrap (defense in depth):

In packages/opencode/src/cli/cmd/tui/context/sync.tsx bootstrap, after project.sync() resolves, call sdk.client.permission.list({ workspace }) and merge the response into store.permission. This recovers any permission ask that was missed by the SSE stream (cold start, reconnect, etc).

C. Bound the deferred await in permission.ask (defense in depth, separate bug):

// permission/index.ts:190-195
return yield* Effect.ensuring(
  Deferred.await(deferred).pipe(
    Effect.timeoutOrElse({
      duration: "5 minutes",
      orElse: () => Effect.fail(new RejectedError()),
    }),
  ),
  Effect.sync(() => { pending.delete(id) }),
)

Even if the dialog never appears (network problem, ACP relay race, future regression), the tool eventually fails instead of hanging the agent forever and pulling memory down with it.

Plugins

No response

OpenCode version

1.15.2 (also affects dev HEAD at f80651fa9 — none of event.ts:19, sync.tsx bootstrap, or permission/index.ts:191 have been touched since the bug landed).

Steps to reproduce

  1. opencode tui in a project.
  2. First prompt: ask the model to read an absolute path outside the worktree. Example: read /etc/hosts on macOS (or any sibling-repo file).
  3. Watch the tool sit pending; no permission dialog renders.
  4. top -pid <pid> shows steadily climbing RSS.
  5. Process is OOM-killed after enough model output has been streamed into the unread tee() branch.

Operating System

macOS 26.5 (arm64). Bug is platform-independent — pure logic race in the TUI event filter.

Terminal

Reproduces in any terminal — affects every TUI permission flow whose event arrives during the project-lookup race, regardless of terminal emulator.

Related

Activity

  1. ton618to commented on May 16, 2026

    @ton618to
    Author

    Additional symptom: ESC abort key becomes completely unresponsive (any number of presses)

    The same event.ts:19 race silently drops session.status events the same way it drops permission.asked events, with a much harsher consequence: the ESC abort keybind becomes completely disabled — no number of presses helps.

    Chain

    1. Server publishes session.status: { type: "busy" } when the prompt loop starts (packages/opencode/src/session/prompt.ts:1652, packages/opencode/src/session/run-state.ts:63, via packages/opencode/src/session/status.ts:79).
    2. That event is published through the same bus.publish → GlobalBus.emit({ project: context.project.id, ... }) path documented in this issue.
    3. If project.project() is still undefined in the TUI when the event arrives, event.ts:19 drops it. The TUI never learns the session is busy.
    4. The TUI's interrupt keybind is gated on session status:
    // packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx:152
    const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
    
    // packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx:466-472
    {
      title: "Interrupt session",
      name: "session.interrupt",
      category: "Session",
      hidden: true,
      enabled: status().type !== "idle",   // ← keybind DISABLED when status stays "idle"
      run: () => { ... if (store.interrupt >= 2) sdk.client.session.abort(...) },
    },
    1. With status stuck at "idle", the run handler is never invoked, regardless of how many times ESC is pressed. The "again to interrupt" hint never appears either (also gated on store.interrupt > 0, which is only incremented inside run).

    This compounds the original symptom: when a tool hangs because permission.asked was dropped, the user cannot recover with ESC because session.status was also dropped and the abort keybind is dead.

    Why this matters for fix prioritization

    Fix A in this issue (relax event.ts:19 filter while project.project() is undefined) is now even more important — it independently restores ESC abort for any user who happens to hit the cold-start race, even on flows that don't involve external_directory. Without it, a hung tool of any kind is unrecoverable: ESC does nothing, the TUI shows nothing pending, and the only way out is force-killing the process.

    Reproduction confirmation

    Press ESC repeatedly during a hang. With the bug active:

    • No "again to interrupt" hint appears at the bottom of the prompt.
    • No abort RPC is sent (verifiable via server logs — no session.abort arrives).

    Once project.project() is loaded (which happens silently in the background after the REST round-trip), session.status events from then on get through, and ESC starts working — but only if the session emits a fresh session.status event. If the session is mid-tool with no further status changes, the keybind stays disabled until the next prompt loop turn.

    Suggested mitigations

    In addition to Fix A, two TUI-side defenses:

    1. Seed session_status on bootstrap alongside the proposed permission.list() seed in Fix B: sdk.client.session.status({ workspace }) is already called at sync.tsx:457 for the initial fetch — but only after the bootstrap promise chain completes; if the user prompts before this finishes, the keybind stays disabled. Move the status fetch earlier or run it eagerly on first session navigation.

    2. Decouple the abort keybind from session status. Pressing ESC while the user has typed nothing in the prompt is a clear intent to abort; the keybind should always be enabled when there's an active sessionID and let the server decide whether there's anything to abort. The current gating is a UX optimization that becomes a hard lock when status is stale.

  2. github-actions commented on Jul 16, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions