Typing in a container terminal is sluggish on Linux, and a backspace can land after the characters typed behind it. Those turned out to be two unrelated defects.
How they were separated
The repo's own web terminal was the control. It shares the Docker exec, the PTY, exec_manager, the input channel and its serial writer task, and xterm.js itself — and shows neither symptom on the same Wayland host. That clears everything below the transport. Only three things differ:
In-app (Tauri)
Web terminal (works)
Input
one invoke() per keystroke, each a separately spawned task
one WebSocket, send_input().awaitinline in a single reader loop
Output
Vec<u8> → JSON array of numbers, ~4–6x bloat
base64 string, ~1.33x
Renderer
WebKitGTK, DMA-BUF disabled, xterm WebGL addon
host browser, canvas renderer, no WebGL
1. Input ordering — the reordered backspace
terminal_input is an async command, so Tauri spawns each keystroke as an independent task. Those tasks then race for the session mutex in ExecSessionManager::send_input; nothing preserved the order the bytes were typed in. The serial writer downstream cannot help, because the order is already lost before anything reaches the channel. The web terminal gets ordering for free by awaiting send_input inline in one reader loop.
useTerminal now holds a per-session queue: one write in flight at a time, the next only after the previous resolves. Anything typed meanwhile coalesces into the next chunk, which also collapses burst typing into a couple of IPC round trips rather than one per key.
Two details worth reviewing:
Module scope, not hook scope.useTerminal() is called from several components (App for speech-to-text, TerminalView for typing and image paste, useProjectActions for tile commands). A per-hook queue would give each its own ordering and leave them racing each other.
await sendInput(...) keeps its meaning. Each caller's promise settles only when its own bytes have gone, and a failed batch rejects only that batch — anything queued behind it still gets its own attempt.
2. The DMA-BUF escape hatch did not exist
apply_webkit_wayland_workaround left any pre-set value alone, including 0, on a stated assumption that WebKitGTK reads the variable as a boolean. It reads presence, so WEBKIT_DISABLE_DMABUF_RENDERER=0 disabled DMA-BUF exactly like =1 did — there was no value a user could set to get the accelerated path back. The comment described an escape hatch that was not there.
0/false/no/empty now remove the variable, which is the only thing WebKitGTK reads as enabled. The default is unchanged: unset still means disabled on Linux, so nobody who was not deliberately overriding this sees any difference.
3. WebGL does not degrade to canvas here
The comment on that workaround assumed @xterm/addon-webgl would fall back to the canvas renderer once DMA-BUF was off, calling it "a real but graceful downgrade". The addon's constructor throws only when WebGL is absent — and with DMA-BUF disabled WebGL is still present, served by software rasterisation. So the addon loads happily and every terminal frame is rendered on the CPU and copied, which is slower than the canvas renderer it was assumed to fall back to, not faster.
AppSettings::terminal_gpu_rendering now decides whether it loads at all, in Settings → Terminal:
None — auto: on for macOS and Windows, off on Linux
Some(true) / Some(false) — force it either way on any platform
Option<bool> rather than bool deliberately: the zero value has to mean "we choose", not "off", or every existing settings file would silently pin the answer at whatever the default was the day it was written.
What is not in here
The output encoding (Vec<u8> as a JSON number array) is real waste and is left alone. The control argues it is not the dominant cost — the web terminal pushes the same volume through a strictly worse transport and stays smooth — so moving it to a Tauri v2 Channel belongs in its own change, measured rather than assumed.
Verification
643 frontend tests pass (50 files), including new coverage for queue ordering, coalescing, per-session independence and failure isolation
530 Rust tests pass, including 3 new ones for the DMA-BUF decision — split into a pure dmabuf_action() so it is testable without mutating process env from a parallel runner
cargo clippy clean, npm run build clean, scripts/scan-secrets.sh clean
Confirmed on real hardware. Verified on CachyOS/Wayland — the exact platform triple-c#34 was reported from — using the preview-3a49a67 AppImage from this PR's own preview release. Both symptoms are gone: the backspace lands in the order it was typed, and the terminal is responsive. That is the terminal_gpu_rendering: None (auto) path, so what was exercised is the queue plus WebGL disabled on Linux.
Regression caught in review
Gating the whole activation branch on the GPU setting made "GPU off" also mean "never re-fit, never focus" when a tab became active — which would have hit exactly the Linux users this targets. The renderer and the activation work are now separate branches.
Typing in a container terminal is sluggish on Linux, and a backspace can land **after** the characters typed behind it. Those turned out to be two unrelated defects.
## How they were separated
The repo's own web terminal was the control. It shares the Docker exec, the PTY, `exec_manager`, the input channel and its serial writer task, and xterm.js itself — and shows neither symptom on the same Wayland host. That clears everything below the transport. Only three things differ:
| | In-app (Tauri) | Web terminal (works) |
|---|---|---|
| Input | one `invoke()` per keystroke, **each a separately spawned task** | one WebSocket, `send_input().await` **inline in a single reader loop** |
| Output | `Vec<u8>` → JSON **array of numbers**, ~4–6x bloat | **base64 string**, ~1.33x |
| Renderer | WebKitGTK, DMA-BUF disabled, xterm **WebGL addon** | host browser, **canvas renderer, no WebGL** |
## 1. Input ordering — the reordered backspace
`terminal_input` is an `async` command, so Tauri spawns each keystroke as an independent task. Those tasks then race for the session mutex in `ExecSessionManager::send_input`; nothing preserved the order the bytes were typed in. The serial writer downstream cannot help, because the order is already lost before anything reaches the channel. The web terminal gets ordering for free by awaiting `send_input` inline in one reader loop.
`useTerminal` now holds a per-session queue: one write in flight at a time, the next only after the previous resolves. Anything typed meanwhile coalesces into the next chunk, which also collapses burst typing into a couple of IPC round trips rather than one per key.
Two details worth reviewing:
- **Module scope, not hook scope.** `useTerminal()` is called from several components (App for speech-to-text, TerminalView for typing and image paste, `useProjectActions` for tile commands). A per-hook queue would give each its own ordering and leave them racing each other.
- **`await sendInput(...)` keeps its meaning.** Each caller's promise settles only when its own bytes have gone, and a failed batch rejects only that batch — anything queued behind it still gets its own attempt.
## 2. The DMA-BUF escape hatch did not exist
`apply_webkit_wayland_workaround` left any pre-set value alone, including `0`, on a stated assumption that WebKitGTK reads the variable as a boolean. It reads **presence**, so `WEBKIT_DISABLE_DMABUF_RENDERER=0` disabled DMA-BUF exactly like `=1` did — there was no value a user could set to get the accelerated path back. The comment described an escape hatch that was not there.
`0`/`false`/`no`/empty now **remove** the variable, which is the only thing WebKitGTK reads as enabled. **The default is unchanged**: unset still means disabled on Linux, so nobody who was not deliberately overriding this sees any difference.
## 3. WebGL does not degrade to canvas here
The comment on that workaround assumed `@xterm/addon-webgl` would fall back to the canvas renderer once DMA-BUF was off, calling it "a real but graceful downgrade". The addon's constructor throws only when WebGL is *absent* — and with DMA-BUF disabled WebGL is still present, served by software rasterisation. So the addon loads happily and every terminal frame is rendered on the CPU and copied, which is **slower** than the canvas renderer it was assumed to fall back to, not faster.
`AppSettings::terminal_gpu_rendering` now decides whether it loads at all, in Settings → Terminal:
- `None` — auto: on for macOS and Windows, off on Linux
- `Some(true)` / `Some(false)` — force it either way on any platform
`Option<bool>` rather than `bool` deliberately: the zero value has to mean "we choose", not "off", or every existing settings file would silently pin the answer at whatever the default was the day it was written.
## What is *not* in here
The output encoding (`Vec<u8>` as a JSON number array) is real waste and is left alone. The control argues it is not the dominant cost — the web terminal pushes the same volume through a strictly worse transport and stays smooth — so moving it to a Tauri v2 `Channel` belongs in its own change, measured rather than assumed.
## Verification
- 643 frontend tests pass (50 files), including new coverage for queue ordering, coalescing, per-session independence and failure isolation
- 530 Rust tests pass, including 3 new ones for the DMA-BUF decision — split into a pure `dmabuf_action()` so it is testable without mutating process env from a parallel runner
- `cargo clippy` clean, `npm run build` clean, `scripts/scan-secrets.sh` clean
**Confirmed on real hardware.** Verified on CachyOS/Wayland — the exact platform triple-c#34 was reported from — using the `preview-3a49a67` AppImage from this PR's own preview release. Both symptoms are gone: the backspace lands in the order it was typed, and the terminal is responsive. That is the `terminal_gpu_rendering: None` (auto) path, so what was exercised is the queue plus WebGL disabled on Linux.
### Regression caught in review
Gating the whole activation branch on the GPU setting made "GPU off" also mean "never re-fit, never focus" when a tab became active — which would have hit exactly the Linux users this targets. The renderer and the activation work are now separate branches.
Two separate defects behind the same report: typing in a container terminal
is sluggish on Linux, and a backspace can land *after* the characters typed
behind it.
The web terminal was the control that separated them. It shares the Docker
exec, the PTY, `exec_manager`, the input channel and its serial writer task,
and xterm.js itself — and it does not exhibit either symptom. Only three
things differ, and each accounts for part of the report.
**Input ordering.** Every keystroke was its own `invoke("terminal_input")`.
That command is `async`, so Tauri spawns each one as an independent task, and
those tasks then race for the session mutex in `ExecSessionManager::send_input`
— nothing preserved the order the bytes were typed in. The serial writer
downstream cannot help, because the order is already lost before anything
reaches the channel. The web terminal gets ordering for free by awaiting
`send_input` inline in a single WebSocket reader loop.
`useTerminal` now holds a per-session queue: one write in flight at a time,
the next only after the previous resolves. Anything typed meanwhile coalesces
into the next chunk, which also collapses a burst of typing into a couple of
IPC round trips rather than one per key. The queue is module scope, not hook
scope, because `useTerminal()` is called from several components — a per-hook
queue would leave speech-to-text, image paste and typing racing each other.
Each caller's promise still settles only when its own bytes have gone, so
`await sendInput(...)` keeps its meaning.
**The DMA-BUF escape hatch did not exist.** `apply_webkit_wayland_workaround`
left any pre-set value alone, including `0`, on a stated assumption that
WebKitGTK reads the variable as a boolean. It reads presence, so
`WEBKIT_DISABLE_DMABUF_RENDERER=0` disabled DMA-BUF exactly like `=1`, and no
value a user could set got the accelerated path back. `0`/`false`/`no`/empty
now remove the variable, which is the only thing WebKitGTK reads as enabled.
The default is unchanged: unset still means disabled on Linux.
**WebGL does not degrade to canvas here.** The comment on that workaround
assumed `@xterm/addon-webgl` would fall back to the canvas renderer once
DMA-BUF was off. Its constructor throws only when WebGL is *absent*, and with
DMA-BUF disabled WebGL is still present — served by software rasterisation.
So the addon loads and every frame is rendered on the CPU, slower than the
canvas renderer it was assumed to fall back to. `AppSettings::terminal_gpu_
rendering` decides whether it loads at all: `None` is auto (on for macOS and
Windows, off on Linux), `Some(_)` forces it either way from Settings →
Terminal. `Option<bool>` rather than `bool` so the zero value means "we
choose" instead of pinning every existing settings file to one answer.
Verified: 643 frontend tests and 530 Rust tests pass, clippy clean, secret
scan clean. The Linux rendering half needs confirming on a real desktop —
neither symptom reproduces in a headless container.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApLYH6ybHwQFkMCtKuHrrV
jknapp
merged commit bd08ce8be2 into main2026-08-28 20:20:54 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Typing in a container terminal is sluggish on Linux, and a backspace can land after the characters typed behind it. Those turned out to be two unrelated defects.
How they were separated
The repo's own web terminal was the control. It shares the Docker exec, the PTY,
exec_manager, the input channel and its serial writer task, and xterm.js itself — and shows neither symptom on the same Wayland host. That clears everything below the transport. Only three things differ:invoke()per keystroke, each a separately spawned tasksend_input().awaitinline in a single reader loopVec<u8>→ JSON array of numbers, ~4–6x bloat1. Input ordering — the reordered backspace
terminal_inputis anasynccommand, so Tauri spawns each keystroke as an independent task. Those tasks then race for the session mutex inExecSessionManager::send_input; nothing preserved the order the bytes were typed in. The serial writer downstream cannot help, because the order is already lost before anything reaches the channel. The web terminal gets ordering for free by awaitingsend_inputinline in one reader loop.useTerminalnow holds a per-session queue: one write in flight at a time, the next only after the previous resolves. Anything typed meanwhile coalesces into the next chunk, which also collapses burst typing into a couple of IPC round trips rather than one per key.Two details worth reviewing:
useTerminal()is called from several components (App for speech-to-text, TerminalView for typing and image paste,useProjectActionsfor tile commands). A per-hook queue would give each its own ordering and leave them racing each other.await sendInput(...)keeps its meaning. Each caller's promise settles only when its own bytes have gone, and a failed batch rejects only that batch — anything queued behind it still gets its own attempt.2. The DMA-BUF escape hatch did not exist
apply_webkit_wayland_workaroundleft any pre-set value alone, including0, on a stated assumption that WebKitGTK reads the variable as a boolean. It reads presence, soWEBKIT_DISABLE_DMABUF_RENDERER=0disabled DMA-BUF exactly like=1did — there was no value a user could set to get the accelerated path back. The comment described an escape hatch that was not there.0/false/no/empty now remove the variable, which is the only thing WebKitGTK reads as enabled. The default is unchanged: unset still means disabled on Linux, so nobody who was not deliberately overriding this sees any difference.3. WebGL does not degrade to canvas here
The comment on that workaround assumed
@xterm/addon-webglwould fall back to the canvas renderer once DMA-BUF was off, calling it "a real but graceful downgrade". The addon's constructor throws only when WebGL is absent — and with DMA-BUF disabled WebGL is still present, served by software rasterisation. So the addon loads happily and every terminal frame is rendered on the CPU and copied, which is slower than the canvas renderer it was assumed to fall back to, not faster.AppSettings::terminal_gpu_renderingnow decides whether it loads at all, in Settings → Terminal:None— auto: on for macOS and Windows, off on LinuxSome(true)/Some(false)— force it either way on any platformOption<bool>rather thanbooldeliberately: the zero value has to mean "we choose", not "off", or every existing settings file would silently pin the answer at whatever the default was the day it was written.What is not in here
The output encoding (
Vec<u8>as a JSON number array) is real waste and is left alone. The control argues it is not the dominant cost — the web terminal pushes the same volume through a strictly worse transport and stays smooth — so moving it to a Tauri v2Channelbelongs in its own change, measured rather than assumed.Verification
dmabuf_action()so it is testable without mutating process env from a parallel runnercargo clippyclean,npm run buildclean,scripts/scan-secrets.shcleanConfirmed on real hardware. Verified on CachyOS/Wayland — the exact platform triple-c#34 was reported from — using the
preview-3a49a67AppImage from this PR's own preview release. Both symptoms are gone: the backspace lands in the order it was typed, and the terminal is responsive. That is theterminal_gpu_rendering: None(auto) path, so what was exercised is the queue plus WebGL disabled on Linux.Regression caught in review
Gating the whole activation branch on the GPU setting made "GPU off" also mean "never re-fit, never focus" when a tab became active — which would have hit exactly the Linux users this targets. The renderer and the activation work are now separate branches.
Two separate defects behind the same report: typing in a container terminal is sluggish on Linux, and a backspace can land *after* the characters typed behind it. The web terminal was the control that separated them. It shares the Docker exec, the PTY, `exec_manager`, the input channel and its serial writer task, and xterm.js itself — and it does not exhibit either symptom. Only three things differ, and each accounts for part of the report. **Input ordering.** Every keystroke was its own `invoke("terminal_input")`. That command is `async`, so Tauri spawns each one as an independent task, and those tasks then race for the session mutex in `ExecSessionManager::send_input` — nothing preserved the order the bytes were typed in. The serial writer downstream cannot help, because the order is already lost before anything reaches the channel. The web terminal gets ordering for free by awaiting `send_input` inline in a single WebSocket reader loop. `useTerminal` now holds a per-session queue: one write in flight at a time, the next only after the previous resolves. Anything typed meanwhile coalesces into the next chunk, which also collapses a burst of typing into a couple of IPC round trips rather than one per key. The queue is module scope, not hook scope, because `useTerminal()` is called from several components — a per-hook queue would leave speech-to-text, image paste and typing racing each other. Each caller's promise still settles only when its own bytes have gone, so `await sendInput(...)` keeps its meaning. **The DMA-BUF escape hatch did not exist.** `apply_webkit_wayland_workaround` left any pre-set value alone, including `0`, on a stated assumption that WebKitGTK reads the variable as a boolean. It reads presence, so `WEBKIT_DISABLE_DMABUF_RENDERER=0` disabled DMA-BUF exactly like `=1`, and no value a user could set got the accelerated path back. `0`/`false`/`no`/empty now remove the variable, which is the only thing WebKitGTK reads as enabled. The default is unchanged: unset still means disabled on Linux. **WebGL does not degrade to canvas here.** The comment on that workaround assumed `@xterm/addon-webgl` would fall back to the canvas renderer once DMA-BUF was off. Its constructor throws only when WebGL is *absent*, and with DMA-BUF disabled WebGL is still present — served by software rasterisation. So the addon loads and every frame is rendered on the CPU, slower than the canvas renderer it was assumed to fall back to. `AppSettings::terminal_gpu_ rendering` decides whether it loads at all: `None` is auto (on for macOS and Windows, off on Linux), `Some(_)` forces it either way from Settings → Terminal. `Option<bool>` rather than `bool` so the zero value means "we choose" instead of pinning every existing settings file to one answer. Verified: 643 frontend tests and 530 Rust tests pass, clippy clean, secret scan clean. The Linux rendering half needs confirming on a real desktop — neither symptom reproduces in a headless container. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApLYH6ybHwQFkMCtKuHrrV