Skip to content

fix(terminal): stop dropped/duplicated chars on macOS fast typing#904

Open
vincentzzh424 wants to merge 3 commits into
crynta:mainfrom
vincentzzh424:fix/terminal-mac-fast-input
Open

fix(terminal): stop dropped/duplicated chars on macOS fast typing#904
vincentzzh424 wants to merge 3 commits into
crynta:mainfrom
vincentzzh424:fix/terminal-mac-fast-input

Conversation

@vincentzzh424

@vincentzzh424 vincentzzh424 commented Jun 30, 2026

Copy link
Copy Markdown

Problem

On macOS, fast typing in the terminal drops or duplicates characters:

  • Fast cdcdcd... comes out ccccc... (every other char dropped).
  • Slow single presses come out doubled (c -> cc).
  • shift + @ / symbols are hard to type.

Other terminals (Terminal.app, iTerm, Alacritty) don't have this.

Root cause

macOS WKWebView tags ordinary fast typing with keyCode 229 ("Process"), not only IME composition. xterm.js skips its own keydown -> onData path for keyCode 229 and instead reads the typed char from textarea input events:

  • Fast typing: the browser coalesces several keystrokes into a single textarea input event, so xterm only emits onData for some of them -> keystrokes dropped.
  • Slow typing: xterm correctly emits onData, but our keydown handler also fires -> each char doubled.

The existing guard if (event.isComposing || event.keyCode === 229) return false; returned false (telling xterm to skip) for all 229 keys, which silently swallowed the fast-typing ones.

Verified with instrumentation: 101 keydowns for cdcdcd..., only 62 onData fired, every dropped key had keyCode 229 and isComposing === false.

Fix

src/modules/terminal/lib/rendererPool.ts:

  • For keyCode === 229 && !isComposing && single-char keys, forward event.key to the PTY directly and tell xterm not to process it.
  • Dedup against xterm's own onData with a short window in both orderings (onData-first and keydown-first), so exactly one copy of each char is sent whether the OS fires the textarea input before or after the keydown.
  • Real IME composition (isComposing) still flows through xterm's compositionend path unchanged.

src-tauri/src/modules/pty/mod.rs:

  • Move pty_write off the webview main thread (async + spawn_blocking) so a burst of keystrokes can't serialize writes on the main thread.

Verification

Tested manually on macOS (Apple Silicon) across all previously-broken cases, now correct:

  • Fast alternating cdcdcd... -> cdcdcd... (was ccccc...)
  • Slow single presses -> single char (was doubled)
  • shift + @, !, ~ symbols -> correct (was flaky)
  • Long-press / key-repeat -> correct count
  • Chinese IME composition -> still works

Instrumentation during debugging confirmed dup = 0 (no doubling), self-send count matched the number of keys xterm dropped.

Checks

  • cargo clippy --locked -> clean
  • cargo test --locked -> 220 passed
  • pnpm check-types -> clean
  • biome lint -> clean (0 new errors)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Resolved occasional duplicate character input in the terminal on macOS WKWebView for certain key events.
    • Improved keyboard-to-PTY forwarding by deduplicating between custom key handling and xterm-delivered input.
    • Enhanced PTY input writing reliability using a bounded, byte-sized queue with safer rollback when the writer is unavailable.
  • Tests
    • Updated PTY session tests to align with the new queued input-writing approach.

@vincentzzh424 vincentzzh424 requested a review from crynta as a code owner June 30, 2026 02:12
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 25746c39-4c2f-47f8-ae35-4a4689fb7424

📥 Commits

Reviewing files that changed from the base of the PR and between 443ff4b and b10cbf9.

📒 Files selected for processing (3)
  • src-tauri/src/modules/pty/mod.rs
  • src-tauri/src/modules/pty/session.rs
  • src/modules/terminal/lib/rendererPool.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src-tauri/src/modules/pty/session.rs
  • src-tauri/src/modules/pty/mod.rs
  • src/modules/terminal/lib/rendererPool.ts

📝 Walkthrough

Walkthrough

pty_write now enqueues PTY input through a bounded session channel, Session drains that channel on a dedicated writer thread, and the terminal renderer deduplicates macOS keyCode-229 character delivery between the key handler and onData.

Changes

PTY input channeling and terminal IME dedup

Layer / File(s) Summary
Session writer channel
src-tauri/src/modules/pty/session.rs
Session replaces its stored writer handle with write_tx, spawn creates a channel-backed writer thread, and the tests build sessions with dummy senders.
pty_write async send
src-tauri/src/modules/pty/mod.rs
pty_write becomes async, clones request bytes, and sends them through session.write_tx with queue-cap backpressure and channel-closed error mapping.
keyCode-229 deduplication
src/modules/terminal/lib/rendererPool.ts
Slot gains selfSentQueue and onDataQueue; the key handler and onData path prune, enqueue, and consume entries to suppress duplicate IME character delivery.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title follows Conventional Commits and accurately summarizes the macOS terminal input fix.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/src/modules/pty/mod.rs`:
- Around line 126-142: The PTY write path in the write handler uses
spawn_blocking per invoke, which can let concurrent jobs reach
session.writer.lock() out of order and scramble input. Update the PTY module’s
write flow to serialize writes per session by routing bytes through a single
queued writer or dedicated writer task, and ensure the existing write_all/error
handling remains inside that ordered path rather than spawning a separate
blocking job for each chunk.

In `@src/modules/terminal/lib/rendererPool.ts`:
- Around line 279-294: The current kc=229 dedup logic in rendererPool’s keydown
handling uses only single-slot state (`selfSentChar`/`selfSentAt` and
`lastOdChar`), which lets a later burst overwrite an earlier pending character
and causes duplicates. Replace those scalar fields with a FIFO queue of pending
`{char, at}` entries in the same keydown/onData flow, and update the matching
logic to consume the oldest matching entry rather than overwriting state. Make
sure the new queue-based handling preserves the existing preventDefault/return
false behavior in the keydown path while correctly deduplicating fast
interleaved input.
- Around line 266-270: The 229 keydown handling in rendererPool’s input path is
using a single shared slot for selfSentChar/lastOdChar, so earlier characters
can be overwritten before onData reads them. Update the keydown tracking in the
relevant rendererPool logic to preserve 229 inputs per event in FIFO order (or
another per-event queue) and consume them in the same order in the onData path,
instead of storing only one pending char.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f142fed3-7be5-4abd-a872-44a7b610f51d

📥 Commits

Reviewing files that changed from the base of the PR and between 3f4d680 and 83a9a45.

📒 Files selected for processing (2)
  • src-tauri/src/modules/pty/mod.rs
  • src/modules/terminal/lib/rendererPool.ts

Comment thread src-tauri/src/modules/pty/mod.rs Outdated
Comment thread src/modules/terminal/lib/rendererPool.ts
Comment thread src/modules/terminal/lib/rendererPool.ts Outdated
vincentzzh424 pushed a commit to vincentzzh424/terax-ai that referenced this pull request Jul 1, 2026
Two correctness issues flagged by review on crynta#904:

1. keyCode-229 dedup used a single scalar slot (selfSentChar/lastOdChar),
   so a fast burst like `cd` overwrote the pending char before its trailing
   onData arrived, leaking the older char through and doubling it
   (cd -> ccd). Replace with per-direction FIFO queues of {char, at} and
   consume the oldest matching entry within the 200ms window.

2. Each pty_write invoke spawned an independent spawn_blocking job that
   contended for session.writer.lock() with no ordering guarantee, so a
   burst of keystrokes could reach the PTY out of order and scramble input.
   Route all writes through a per-session mpsc channel drained by a single
   dedicated writer thread; pty_write now does a non-blocking send.

Verified: cargo clippy --locked (clean), cargo test --locked (220 passed),
pnpm check-types (clean), biome lint (0 new errors).

Co-Authored-By: Claude <noreply@anthropic.com>
@vincentzzh424

Copy link
Copy Markdown
Author

Addressed the three CodeRabbit review comments in 889af52:

1. PTY write ordering (mod.rs)pty_write no longer spawns an independent spawn_blocking job per invoke. Each session now owns an mpsc::Sender<Vec<u8>> drained by a single dedicated writer thread (terax-pty-writer), so a burst of keystrokes reaches the PTY in arrival order instead of racing for writer.lock(). pty_write is now a non-blocking channel send. The now-unused Session.writer field was removed; both the writer thread and the reader thread (for DA replies) hold their own clones.

2 & 3. Single-slot kc=229 dedup (rendererPool.ts) — Replaced the scalar selfSentChar/lastOdChar slots with per-direction FIFO queues of {char, at}. keydown enqueues an entry and onData consumes the oldest matching one within the 200ms window (and vice-versa for the onData-first ordering). This fixes the cd -> ccd regression where a second quick keydown overwrote the first's pending state before its delayed onData arrived. preventDefault/return false behavior in the keydown path is unchanged.

Verified: cargo clippy --locked (clean), cargo test --locked (220 passed), pnpm check-types (clean), biome lint (0 new errors).

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
cd /tmp
gh pr diff 904 --repo crynta/terax-ai > /tmp/pr904.diff 2>&1 || true
wc -l /tmp/pr904.diff
echo "---"
sed -n '1,400p' /tmp/pr904.diff

Length of output: 12637


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cd /tmp
git clone --depth 50 --branch $(gh pr view 904 --repo crynta/terax-ai --json headRefName -q .headRefName) https://github.com/crynta/terax-ai.git repo 2>&1 | tail -5
cd repo
echo "---writer usage in session.rs---"
rg -n 'writer' src-tauri/src/modules/pty/session.rs
echo "---DA reply / reader thread context---"
rg -n 'reader|DA reply|thread::spawn' -A5 -B5 src-tauri/src/modules/pty/session.rs | head -150

Length of output: 992


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cd /tmp
rm -rf repo
git clone --depth 1 https://github.com/crynta/terax-ai.git repo 2>&1 | tail -5
cd repo
git fetch origin refs/pull/904/head:pr904 --depth 50 2>&1 | tail -10
git checkout pr904
echo "---writer usage in session.rs---"
rg -n 'writer' src-tauri/src/modules/pty/session.rs
echo "---thread::spawn / reader thread context---"
rg -n 'thread::spawn|fn spawn|read_to' -A5 -B5 src-tauri/src/modules/pty/session.rs | head -200

Length of output: 2411


🏁 Script executed:

#!/bin/bash
cd /tmp/repo
rg -n 'pty_write|writeToPty' -A5 -B2 src/modules/terminal --type=ts | head -100

Length of output: 5364


@vincentzzh424 Verified 889af52 against all three prior comments — solid fixes, no regressions found.

1. Write ordering — the dedicated terax-pty-writer thread draining an mpsc::Sender<Vec<u8>> correctly serializes writes in arrival order. Confirmed the DA-reply path (writer_for_da) still writes through the same shared Mutex<Box<dyn Write + Send>>, so there's no data race between the two writer sites — just no strict interleave ordering between them, which is fine since DA replies are infrequent synchronous responses, not bursty keystroke traffic.

2 & 3. kc=229 dedup — the FIFO-queue approach (selfSentQueue/onDataQueue, oldest-match consumption, 200ms window) correctly fixes the single-slot overwrite bug you diagnosed (cd -> ccd). Each pending char now has its own queue entry rather than clobbering a scalar field.

Two minor, non-blocking notes for awareness (not asking for changes):

  • The channel is unbounded, so if the underlying PTY write ever blocks indefinitely (e.g., child hung, pipe full), queued Vec<u8> writes would accumulate in memory rather than applying backpressure. Very unlikely in practice for a terminal app, but worth a mental bookmark if you ever see memory growth reports tied to a hung shell.
  • pty_write no longer surfaces write failures (e.g., EPIPE) back to the JS caller — they're only log::debug!'d in the writer thread now. I checked pty-bridge.ts and the invoke("pty_write", ...) call was already fire-and-forget (result not awaited/checked), so this isn't an actual behavior regression, just noting the trade-off.

Nice diagnosis on the keyCode 229/WKWebView quirk — approving this fix.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/src/modules/pty/session.rs`:
- Around line 43-47: The new comments in Session cleanup are using em dash
characters, which violates the repo text convention. Update the comment block in
Session::drop-related cleanup so the prose uses plain hyphens or commas instead
of em dashes, and keep the wording otherwise unchanged. Use the existing symbols
write_tx, master, and the surrounding reader/writer thread comments to locate
the affected text.
- Around line 147-151: The PTY input queue in `session.rs` is unbounded because
`mpsc::channel` lets `pty_write` keep accepting data even when the writer can’t
drain it, causing potential RAM growth. Update the dedicated writer path around
`write_tx`/`write_rx` to use a bounded queue or enforce a byte cap, and make
`pty_write` surface backpressure when the limit is hit instead of always
succeeding. Keep the serialized write behavior in `writer.lock()` intact while
preventing large pastes or stalled PTYs from accumulating unlimited `Vec<u8>`
buffers.
- Around line 156-160: The PTY writer loop in session handling keeps draining
`write_rx` after `write_all` or the writer lock fails, so `pty_write` can
continue accepting data even though writes are no longer possible. Update the
write loop in `session.rs` to stop processing immediately on either
`writer_for_writer.lock()` failure or `w.write_all(&bytes)` error, letting the
receiver close so `pty_write` sees the channel-closed error. Use the existing
`write_rx`, `writer_for_writer`, and `pty_write` flow to locate the fix.

In `@src/modules/terminal/lib/rendererPool.ts`:
- Around line 290-295: The repeated-key handling in rendererPool’s onDataQueue
matching is too broad because findIndex by char can remove an older
same-character entry instead of the current keystroke. Update the logic around
slot.onDataQueue and the event.key handling to match a specific keystroke
instance in order, not just by character value, so later repeats within the 200
ms window are not dropped.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0032db21-30bf-4146-a83e-676f7410fc54

📥 Commits

Reviewing files that changed from the base of the PR and between 83a9a45 and 889af52.

📒 Files selected for processing (3)
  • src-tauri/src/modules/pty/mod.rs
  • src-tauri/src/modules/pty/session.rs
  • src/modules/terminal/lib/rendererPool.ts

Comment thread src-tauri/src/modules/pty/session.rs Outdated
Comment thread src-tauri/src/modules/pty/session.rs Outdated
Comment thread src-tauri/src/modules/pty/session.rs Outdated
Comment thread src/modules/terminal/lib/rendererPool.ts
vincentzzh424 pushed a commit to vincentzzh424/terax-ai that referenced this pull request Jul 1, 2026
Address the second round of CodeRabbit review on crynta#904:

- Bound the PTY input queue with a byte cap (WRITE_QUEUE_CAP, 16 MiB)
  tracked via an AtomicUsize. pty_write reserves capacity before
  enqueueing and rolls back on send failure, so a stuck PTY (child hung,
  pipe full) surfaces backpressure instead of growing memory unbounded.
- The writer thread now breaks on write or lock failure. Dropping the
  receiver closes the channel, so later pty_write calls get a
  channel-closed error rather than silently discarding input.
- Remove em dashes from touched comments per the repo-wide convention
  (TERAX.md: no em-dash anywhere).

Verified: cargo clippy --locked (clean), cargo test --locked (221
passed), pnpm check-types (clean), biome lint (0 new errors).

Co-Authored-By: Claude <noreply@anthropic.com>
@vincentzzh424

Copy link
Copy Markdown
Author

Ready for review. All CodeRabbit threads (both rounds) are resolved:

  • Round 1 (write ordering, single-slot kc=229 dedup): fixed in 889af52.
  • Round 2 (em dashes, bounded input queue, break-on-writer-failure): fixed in 443ff4b. The fourth round-2 comment (repeated-key dedup) was rebutted with a trace - the FIFO oldest-match already handles consecutive repeats correctly (aa -> aa, not aaa); details in the thread reply.

All local checks green: cargo clippy --locked (clean), cargo test --locked (221 passed), pnpm check-types (clean), biome lint (0 new errors).

Two things still need a maintainer (both require admin on crynta/terax-ai, which I don't have):

  1. Approve and run the CI workflows for this fork PR (the 4 action_required runs - coverage / frontend / rust / rust-test).
  2. Code review and merge.

@crynta when you have a moment.

vincentzzh424 and others added 3 commits July 3, 2026 10:42
macOS WKWebView tags ordinary fast typing with keyCode 229 ("Process"),
not just IME. xterm.js skips its keydown->onData path for keyCode 229 and
reads chars from textarea input events, which the browser coalesces under
fast typing -- so some keystrokes never emit onData (chars dropped, e.g.
cdcdcd came out ccccc) and at slow speed both our self-send and xterm's
onData fire (chars doubled, e.g. c came out cc).

Self-send keyCode-229 single-char keystrokes directly to the PTY and
dedup against xterm's onData with a short window in both orderings, so
exactly one copy of each char is sent. Real IME composition (isComposing)
still goes through xterm's compositionend path unchanged.

Also move pty_write off the webview main thread (async + spawn_blocking)
so a burst of keystrokes can't serialize writes there.

Co-Authored-By: Claude <noreply@anthropic.com>
Two correctness issues flagged by review on crynta#904:

1. keyCode-229 dedup used a single scalar slot (selfSentChar/lastOdChar),
   so a fast burst like `cd` overwrote the pending char before its trailing
   onData arrived, leaking the older char through and doubling it
   (cd -> ccd). Replace with per-direction FIFO queues of {char, at} and
   consume the oldest matching entry within the 200ms window.

2. Each pty_write invoke spawned an independent spawn_blocking job that
   contended for session.writer.lock() with no ordering guarantee, so a
   burst of keystrokes could reach the PTY out of order and scramble input.
   Route all writes through a per-session mpsc channel drained by a single
   dedicated writer thread; pty_write now does a non-blocking send.

Verified: cargo clippy --locked (clean), cargo test --locked (220 passed),
pnpm check-types (clean), biome lint (0 new errors).

Co-Authored-By: Claude <noreply@anthropic.com>
Address the second round of CodeRabbit review on crynta#904:

- Bound the PTY input queue with a byte cap (WRITE_QUEUE_CAP, 16 MiB)
  tracked via an AtomicUsize. pty_write reserves capacity before
  enqueueing and rolls back on send failure, so a stuck PTY (child hung,
  pipe full) surfaces backpressure instead of growing memory unbounded.
- The writer thread now breaks on write or lock failure. Dropping the
  receiver closes the channel, so later pty_write calls get a
  channel-closed error rather than silently discarding input.
- Remove em dashes from touched comments per the repo-wide convention
  (TERAX.md: no em-dash anywhere).

Verified: cargo clippy --locked (clean), cargo test --locked (221
passed), pnpm check-types (clean), biome lint (0 new errors).

Co-Authored-By: Claude <noreply@anthropic.com>
@vincentzzh424 vincentzzh424 force-pushed the fix/terminal-mac-fast-input branch from 443ff4b to b10cbf9 Compare July 3, 2026 02:42
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.

1 participant