Repository navigation
TUI silently drops permission.asked events during project-lookup race → tool hangs forever → OOM #27879
Description
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
- Open the TUI in a project. (Don't run
permission.list()first — that doesn't happen on bootstrap.) - 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. - Tool call sits in
running/pendingstate. No<PermissionPrompt>ever renders. - RSS climbs at ~KB-MB/sec while the model continues to stream tokens that opencode can't consume past the parked tool fiber.
- 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.askatpermission/index.ts:191doesDeferred.await(deferred)with no timeout.- While the read tool's Effect fiber is parked on that deferred, ai-sdk's
streamTextcontinues to drain the model SSE becauseresult.fullStreamis implemented viabaseStream.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,80usesPubSub.unbounded; per-deltabus.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
opencode tuiin a project.- First prompt: ask the model to read an absolute path outside the worktree. Example:
read /etc/hostson macOS (or any sibling-repo file). - Watch the tool sit pending; no permission dialog renders.
top -pid <pid>shows steadily climbing RSS.- 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
readtool reads entire file regardless of MAX_BYTES limit (200× slowdown after #27155) #27864 — same family of "silent drop / unbounded growth caused by Effect migration cleanup"- Permission asks from nested subagent sessions silently hang #13715 —
children()memo loses grandchild permission events (different break point, same family of "permission.asked silently swallowed") - Agent hangs indefinitely when using
opencode serve+opencode attachif any permission is set to "ask" #16367 —opencode serve + attachpermission relay race (different scenario, same shape: deferred awaits forever with no UI surface) - Truncated tool calls are misclassified and unrecoverable (finishReason: length + repairToolCall + doom loop) #18108 — explicitly identifies
Permission.askDeferred has no timeout and blocks forever in headless contexts - Memory leak: SSE connections stuck in CLOSE_WAIT cause unbounded AsyncQueue growth (~14 MB/sec) #22198 — SSE CLOSE_WAIT + unbounded AsyncQueue (related OOM-while-stalled mechanism)
- perf: prompt loop loads entire conversation history into memory on every step #18136 — prompt loop loads full history per step (compounds the OOM during a stalled tool)
Activity
ton618to commentedon May 16, 2026
Additional symptom: ESC abort key becomes completely unresponsive (any number of presses)
The same
event.ts:19race silently dropssession.statusevents the same way it dropspermission.askedevents, with a much harsher consequence: the ESC abort keybind becomes completely disabled — no number of presses helps.Chain
session.status: { type: "busy" }when the prompt loop starts (packages/opencode/src/session/prompt.ts:1652,packages/opencode/src/session/run-state.ts:63, viapackages/opencode/src/session/status.ts:79).bus.publish→GlobalBus.emit({ project: context.project.id, ... })path documented in this issue.project.project()is stillundefinedin the TUI when the event arrives,event.ts:19drops it. The TUI never learns the session is busy."idle", therunhandler is never invoked, regardless of how many times ESC is pressed. The "again to interrupt" hint never appears either (also gated onstore.interrupt > 0, which is only incremented insiderun).This compounds the original symptom: when a tool hangs because
permission.askedwas dropped, the user cannot recover with ESC becausesession.statuswas also dropped and the abort keybind is dead.Why this matters for fix prioritization
Fix A in this issue (relax
event.ts:19filter whileproject.project()isundefined) 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 involveexternal_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:
session.abortarrives).Once
project.project()is loaded (which happens silently in the background after the REST round-trip),session.statusevents from then on get through, and ESC starts working — but only if the session emits a freshsession.statusevent. 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:
Seed
session_statuson bootstrap alongside the proposedpermission.list()seed in Fix B:sdk.client.session.status({ workspace })is already called atsync.tsx:457for 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.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.
github-actions commentedon Jul 16, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.