Skip to content

fix(tui): answer a permission request at most once - #2601

Open
Yi-111-a wants to merge 1 commit into
XiaomiMiMo:mainfrom
Yi-111-a:fix/permission-reply-once
Open

Yi-111-a wants to merge 1 commit into
XiaomiMiMo:mainfrom
Yi-111-a:fix/permission-reply-once

Conversation

@Yi-111-a

@Yi-111-a Yi-111-a commented Oct 2, 2026

Copy link
Copy Markdown

Fixes #2565

Problem

The permission dialog is only torn down once the server's permission.replied event comes back. Until then the prompt keeps handling keys, and every one of them re-POSTed the same reply:

11:14:16 x2 -> 11:14:17 -> 11:14:20 -> ... -> 11:14:33   (Enter held down)

The report logged 32 replies for one requestID over ~8 minutes, and 141 in another session. The storm eventually wedged @opentui/core's setRawMode with EIO, after which Enter, Esc and Ctrl+C were all dead for the rest of the session — only kill -9 recovered it:

ERROR service=server-proxy e=setRawMode failed with errno: 5 exception
ERROR service=server-proxy error=EIO: i/o error, write exception

Cause

PermissionRequestPrompt calls sdk.client.permission.reply() directly from three places — the once/reject path, the always confirmation, and the reject-reason stage — with no guard. The server already drops a duplicate reply, so each extra POST was individually harmless; the storm was the whole problem.

Fix

Latch the reply per request:

let replied = false
const reply = (body: { reply: "once" | "always" | "reject"; message?: string }) => {
  if (replied) return
  replied = true
  void sdk.client.permission.reply({ ...body, requestID: props.request.id })
}

Per request rather than per prompt mount, because a confirmation answers and then drops back to the first stage:

onSelect={(option) => {
  setStore("stage", "permission")
  if (option === "cancel") return
  reply({ reply: "always" })
}}

A per-prompt latch is not sufficient here — the always stage sends its reply and returns to the permission stage, so a second Enter lands on a freshly mounted prompt that would answer again with once. Holding Enter through a confirmation therefore produced always then once for one request. The per-request latch covers all three call sites at once.

This is a behaviour fix only. Which reply wins is unchanged for every single-keystroke interaction, and no dialog, stage or keybinding changes.

Validation

Four regression tests in test/cli/tui/permission-bash-delete.test.tsx, on the existing mountPermission harness which records every /permission/*/reply POST:

test covers
PermissionPrompt answers a held RETURN once once, three Enters in one mount
PermissionPrompt answers a held ESCAPE once reject, two Escapes
always confirmation answers a held RETURN once always, two Enters on the confirmation stage
reject stage answers a held RETURN once reject via RejectPrompt (harness gained an optional parentID so the request routes through the reject-reason stage)

Before / after, on the same tree:

before   29 pass   3 fail      (the three new held-Enter cases)
after    32 pass   0 fail

Also run:

  • bun test test/cli/tui — 279 pass, 2 fail. Both failures are in session-list-visibility.test.ts, which scans source text for an absolute file:///… import and does not match this checkout's path. They fail identically on pristine main (275 pass, 2 fail), which is the same run plus these 4 tests.
  • bun run typecheck (tsgo --noEmit) — clean.
  • bunx oxlint on both changed files — 0 errors; the 2 warnings in permission.tsx (lines 56-57) are identical on pristine main.

permission-bash-delete.test.tsx is not prettier-clean on main either, so I left the surrounding formatting alone rather than reformatting the file inside this diff; the added tests follow the style already in it.

🤖 Prepared with AI assistance.

The permission dialog only unmounts after the server's `permission.replied`
event comes back, so every key held down across that round trip re-POSTed
`/permission/{id}/reply` for the same request. A run in XiaomiMiMo#2565 logged 32
replies for one id and 141 for another; the storm eventually wedged
`setRawMode` with EIO and left Enter, Esc and Ctrl+C dead for the rest of
the session, recoverable only with SIGKILL.

The server ignores a duplicate reply, so the extra POSTs were harmless
individually and invisible in behaviour -- only the storm itself hurt.

Latch the reply per request rather than per prompt mount. Per prompt is not
enough: the `always` confirmation answers and then drops back to the first
stage, so a second Enter lands on a fresh prompt that would answer again
with `once`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: 权限确认框重复提交 permission.reply 导致 TTY 崩溃(setRawMode EIO),键盘永久失效

1 participant