Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 16 additions & 20 deletions crates/noa-app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use noa_grid::{
CursorStyle, PromptJump, Terminal,
modes::{MouseFormat, MouseTracking},
};
use noa_pty::{Pty, PtyConfig};
use noa_pty::{Pty, PtyConfig, PtyWriter};
use noa_render::{
BackgroundImage, CardPipeline, CardStyle, CardTexturePlacement, CardTilePlacement,
CommandPaletteSnapshot, FrameSnapshot, HoverLink, OverviewThumbnailResources, PaletteRow,
Expand Down Expand Up @@ -764,28 +764,24 @@ impl App {
.map(|state| state.window.clone())
}

fn app_cursor_keys(&self, window_id: WindowId) -> bool {
/// The three keyboard-encoding modes read on every key-encode pass,
/// returned `(app_cursor_keys, app_keypad, kitty_keyboard_flags)` under a
/// single terminal lock. One acquisition rather than three keeps the
/// key-input path off the io thread's output-batch lock longer than
/// necessary (input-latency under heavy pty output).
fn key_encode_modes(&self, window_id: WindowId) -> (bool, bool, u8) {
self.windows
.get(&window_id)
.and_then(WindowState::focused_surface)
.map(|surface| surface.terminal.lock().modes.app_cursor_keys())
.unwrap_or(false)
}

fn app_keypad(&self, window_id: WindowId) -> bool {
self.windows
.get(&window_id)
.and_then(WindowState::focused_surface)
.map(|surface| surface.terminal.lock().modes.app_keypad())
.unwrap_or(false)
}

fn kitty_keyboard_flags(&self, window_id: WindowId) -> u8 {
self.windows
.get(&window_id)
.and_then(WindowState::focused_surface)
.map(|surface| surface.terminal.lock().kitty_keyboard_flags())
.unwrap_or(0)
.map(|surface| {
let terminal = surface.terminal.lock();
(
terminal.modes.app_cursor_keys(),
terminal.modes.app_keypad(),
terminal.kitty_keyboard_flags(),
)
})
.unwrap_or((false, false, 0))
}

fn focus_reporting(&self, window_id: WindowId) -> bool {
Expand Down
4 changes: 1 addition & 3 deletions crates/noa-app/src/app/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -724,9 +724,7 @@ impl ApplicationHandler<UserEvent> for App {
) {
return;
}
let app_cursor_keys = self.app_cursor_keys(window_id);
let app_keypad = self.app_keypad(window_id);
let kitty_flags = self.kitty_keyboard_flags(window_id);
let (app_cursor_keys, app_keypad, kitty_flags) = self.key_encode_modes(window_id);
let unmodified_key = event.key_without_modifiers();
// On macOS, Option only acts as Alt when winit stripped its
// composition per `macos-option-as-alt` — i.e. the delivered
Expand Down
25 changes: 24 additions & 1 deletion crates/noa-app/src/app/input_ops/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,30 @@ impl App {
return crate::io_thread::QueueInputResult::Disconnected;
};
match &surface.transport {
SurfaceTransport::Local(local) => local.pty_input_tx.queue(bytes),
// Reserve against the pane's shared byte budget, then write
// straight to the PTY writer thread — bypassing the io thread's
// output-batch loop so a keystroke isn't stuck behind a large
// pty-output batch. The reservation travels with the bytes and is
// released after the real write, preserving the overflow-cap
// accounting `PtyInputQueue::queue` would apply.
SurfaceTransport::Local(local) => match local.pty_input_tx.reserve(bytes) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve queued PTY input ordering when bypassing the io thread

When a raw attach/IPC input is already sitting in PtyInputQueue (especially in the overflow drainer), this fast path only reserves budget and then writes the later local key/paste directly to PtyWriter, so it can reach the shell before earlier attach bytes that are still waiting for the io thread to forward them. RawAttachTap::queue_input still uses PtyInputQueue::queue, whose overflow path exists to keep later keys behind deferred paste bytes; bypassing that queue corrupts the PTY byte-stream order whenever local and attach input overlap under backpressure.

Useful? React with 👍 / 👎.

Some(reserved) => {
// The echo-repaint generation advances when the writer
// thread completes the real PTY write (the wrapper's
// Drop), not here: output the io thread was already
// parsing predates this input and must not consume the
// echo debt.
let stamped = crate::io_thread::EchoStampedInput::new(
reserved,
local.input_echo_seq.clone(),
);
match local.pty_writer.write_owned(stamped) {
Ok(()) => crate::io_thread::QueueInputResult::Queued,
Err(_) => crate::io_thread::QueueInputResult::Disconnected,
}
}
None => crate::io_thread::QueueInputResult::Dropped,
},
SurfaceTransport::Remote(remote) => {
if remote
.connection
Expand Down
102 changes: 74 additions & 28 deletions crates/noa-app/src/app/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,49 @@ impl App {

let caps = surface.get_capabilities(&adapter);
let alpha_blending = alpha_blending_mode(&self.config.font);

// If the window landed on a scale factor the monitor probe didn't
// predict, the probe-sized surface would be the wrong physical
// size. Settle the scale-corrected size *before* the first
// `configure`/present so only one CAMetalLayer drawable generation
// is ever allocated: drawables are allocated lazily by the first
// `get_current_texture` (the present below), never by `configure`
// alone, so a second `request_inner_size`-driven generation would
// otherwise stay resident alongside the correct one. Deriving the
// corrected size needs the real window-scale metrics, so on this
// path the terminal font stack is joined up front; the common
// (matched-scale) path still presents the startup frame before the
// join to keep first paint early.
let font_px = font_pixel_size(self.runtime_font_size, window_scale_factor);
let mut terminal_stack_handle = Some(terminal_stack_handle);
let mut prebuilt_font: Option<FontGrid> = None;
let mut configure_size = None;
if (window_scale_factor - monitor_scale_factor).abs() > f64::EPSILON {
let font = build_terminal_font(
terminal_stack_handle.take().expect("handle present"),
font_px,
font_config_from_noa_config(&self.config.font),
);
let corrected = initial_window_logical_size(
font.metrics(),
initial_grid_size,
window_scale_factor,
self.padding,
);
// macOS applies this synchronously and returns the new size, so
// the surface is configured at the corrected size on its first
// (and only) `configure`. A platform that applies it
// asynchronously returns `None`; the fall-through to
// `window.inner_size()` then keeps the pre-correction size and
// the later `Resized` reconfigures as before.
configure_size = window.request_inner_size(corrected);
prebuilt_font = Some(font);
}

let surface_config = build_surface_config(
&caps,
alpha_blending,
window.inner_size(),
configure_size.unwrap_or_else(|| window.inner_size()),
self.config.background_opacity < 1.0,
);
surface.configure(&device, &surface_config);
Expand Down Expand Up @@ -340,35 +379,16 @@ impl App {

// The window is on screen; now join the terminal font stack (the
// worker has been running since the top of `crate::run`) and
// finish grid construction at the window's actual scale.
let font = {
let stack = match terminal_stack_handle.join() {
Ok(Ok(stack)) => stack,
Ok(Err(e)) => panic!("failed to load a system monospace font: {e:?}"),
Err(_) => panic!("failed to load a system monospace font: worker panicked"),
};
let font = FontGrid::with_stack(
stack,
font_pixel_size(self.runtime_font_size, window_scale_factor),
// finish grid construction at the window's actual scale. The
// scale-mismatch path above already joined it to size the surface.
let font = match prebuilt_font {
Some(font) => font,
None => build_terminal_font(
terminal_stack_handle.take().expect("handle present"),
font_px,
font_config_from_noa_config(&self.config.font),
)
.expect("failed to load a system monospace font");
crate::startup_trace::mark("font-ready");
font
),
};
// The probe metrics that sized the window were computed at the
// monitor's scale; if the window landed on a different scale the
// real metrics differ — correct the size now (the Resized event
// reconfigures the surface), matching the old inline-rebuild path.
if (window_scale_factor - monitor_scale_factor).abs() > f64::EPSILON {
let corrected = initial_window_logical_size(
font.metrics(),
initial_grid_size,
window_scale_factor,
self.padding,
);
let _ = window.request_inner_size(corrected);
}

self.gpu = Some(GpuState {
instance,
Expand Down Expand Up @@ -766,6 +786,10 @@ impl App {
terminal.clone(),
pty_input_tx.clone(),
);
// Main-thread writer clone taken before `pty` moves into the io thread,
// so keyboard/paste input writes straight to the writer thread.
let pty_writer = pty.writer();
let input_echo_seq = Arc::new(AtomicU64::new(0));
let io_thread = crate::io_thread::spawn(
pty,
terminal.clone(),
Expand All @@ -774,6 +798,7 @@ impl App {
resize_rx,
pty_input_rx,
auto_approve_feedback_rx,
input_echo_seq.clone(),
overview_publish,
sidebar_publish,
auto_approve,
Expand All @@ -786,6 +811,8 @@ impl App {
terminal,
SurfaceTransport::Local(LocalSurfaceTransport {
pty_input_tx,
pty_writer,
input_echo_seq,
auto_approve_feedback_tx,
resize_tx,
io_thread: Some(io_thread),
Expand Down Expand Up @@ -1443,6 +1470,25 @@ impl App {
}
}

/// Join the terminal font-discovery worker and build the primary [`FontGrid`]
/// at `px_size`. A join or parse failure is fatal — the terminal cannot render
/// without a monospace face — matching the old inline load.
fn build_terminal_font(
handle: std::thread::JoinHandle<Result<noa_font::FontStack, noa_font::FontError>>,
px_size: f32,
font_cfg: noa_font::FontConfig,
) -> FontGrid {
let stack = match handle.join() {
Ok(Ok(stack)) => stack,
Ok(Err(e)) => panic!("failed to load a system monospace font: {e:?}"),
Err(_) => panic!("failed to load a system monospace font: worker panicked"),
};
let font = FontGrid::with_stack(stack, px_size, font_cfg)
.expect("failed to load a system monospace font");
crate::startup_trace::mark("font-ready");
font
}

fn remote_status_preview(text: &str) -> Vec<crate::session_store::PreviewLine> {
vec![vec![crate::session_store::PreviewSpan {
text: text.to_string(),
Expand Down
16 changes: 16 additions & 0 deletions crates/noa-app/src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,22 @@ pub(super) enum SurfaceTransport {

pub(super) struct LocalSurfaceTransport {
pub(super) pty_input_tx: crate::io_thread::PtyInputQueue,
/// Main-thread handle to the PTY writer thread. Keyboard/paste/IME input
/// is written here directly (after reserving on `pty_input_tx`), skipping
/// the io thread's output-batch loop so a keystroke never waits out a
/// large pty-output batch (input-latency fix). The writer channel is MPSC:
/// the io thread's own DSR/DA replies share it, and independent-producer
/// interleaving is acceptable (same model as a separated termio writer).
pub(super) pty_writer: PtyWriter,
/// Shared with this pane's io thread: incremented when the writer thread
/// completes an input's real PTY write (`EchoStampedInput`'s Drop), so
/// the echo batch bypasses the redraw floor. A generation counter rather
/// than a bool: the io thread consumes only the generation it observed at
/// redraw-decision time, so an input landing between its load and consume
/// is never lost — and advancing at the real write (not queue time) keeps
/// output parsed before the write from consuming the debt. See
/// `io_thread::spawn`'s `input_echo_served`.
pub(super) input_echo_seq: Arc<AtomicU64>,
pub(super) auto_approve_feedback_tx: Sender<crate::io_thread::AutoApproveFeedback>,
pub(super) resize_tx: Sender<GridSize>,
pub(super) io_thread: Option<crate::io_thread::IoThreadHandle>,
Expand Down
2 changes: 1 addition & 1 deletion crates/noa-app/src/io_thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ use sidebar::*;
use spawn::*;

pub(crate) use auto_approve::{AutoApproveFeedback, AutoApprovePublish};
pub(crate) use input_queue::{PtyInputQueue, QueueInputResult, input_channel};
pub(crate) use input_queue::{EchoStampedInput, PtyInputQueue, QueueInputResult, input_channel};
pub(crate) use ipc_tap::IpcOutputTap;
pub(crate) use overview::{OverviewPublish, publish_overview_snapshot};
pub(crate) use raw_attach::RawAttachTap;
Expand Down
45 changes: 44 additions & 1 deletion crates/noa-app/src/io_thread/input_queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//! ordered overflow buffer for bursts (huge pastes) the channel can't absorb.

use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};

use crossbeam_channel::{Receiver, Sender, TrySendError};
use parking_lot::Mutex;
Expand Down Expand Up @@ -67,6 +67,36 @@ impl Drop for QueuedPtyInput {
}
}

/// A reserved input that advances the pane's echo-repaint generation
/// (`input_echo_seq`) only when the writer thread drops it — i.e. after the
/// real PTY write completes (or the write is abandoned on a dead pane, where
/// the extra repaint is moot). Bumping at queue time instead would let an
/// output batch the io thread was already parsing — necessarily pre-echo
/// output — consume the debt, sending the actual echo through the normal
/// redraw floor.
pub(crate) struct EchoStampedInput {
input: QueuedPtyInput,
echo_seq: Arc<AtomicU64>,
}

impl EchoStampedInput {
pub(crate) fn new(input: QueuedPtyInput, echo_seq: Arc<AtomicU64>) -> Self {
Self { input, echo_seq }
}
}

impl AsRef<[u8]> for EchoStampedInput {
fn as_ref(&self) -> &[u8] {
self.input.as_ref()
}
}

impl Drop for EchoStampedInput {
fn drop(&mut self) {
self.echo_seq.fetch_add(1, Ordering::Relaxed);
}
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum QueueInputResult {
Queued,
Expand Down Expand Up @@ -123,6 +153,19 @@ impl PtyInputQueue {
}
}

/// Reserve `input` against this pane's shared byte budget without routing
/// it through the io thread's input channel, returning the reservation
/// wrapper for the caller to hand straight to the PTY writer (the
/// main-thread input fast path — keystrokes must not wait out the io
/// thread's output batch). `None` means the pane is at
/// [`PTY_INPUT_PENDING_BYTE_CAP`] and the input must be dropped, exactly
/// as [`queue`](Self::queue) would return [`QueueInputResult::Dropped`].
/// The returned wrapper shares this pane's budget with `queue`, so both
/// paths are capped together.
pub(crate) fn reserve(&self, input: PtyInput) -> Option<QueuedPtyInput> {
QueuedPtyInput::reserve(input, Arc::clone(&self.pending_bytes)).ok()
}

/// Queue `input` behind every byte accepted before it, blocking never.
pub(crate) fn queue(&self, input: PtyInput) -> QueueInputResult {
let Ok(input) = QueuedPtyInput::reserve(input, Arc::clone(&self.pending_bytes)) else {
Expand Down
Loading