Skip to content

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

@aschina

Description

@aschina

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 assertExternalDirectoryEffectctx.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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions