From 9bad2745f77181b1ea381b60bdac4e2f2228a11b Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 23:24:16 +0900 Subject: [PATCH] fix: measure and defer panel startup reload --- .yoi/tickets/00001KV5MRH6D/item.md | 2 +- .yoi/tickets/00001KV5MRH6D/thread.md | 35 ++++++++ crates/tui/src/multi_pod.rs | 12 +-- crates/tui/src/single_pod.rs | 49 +++++++++-- tests/e2e/tests/panel.rs | 124 ++++++++++++++++++++++++++- 5 files changed, 206 insertions(+), 16 deletions(-) diff --git a/.yoi/tickets/00001KV5MRH6D/item.md b/.yoi/tickets/00001KV5MRH6D/item.md index c6287b14..fede2a0d 100644 --- a/.yoi/tickets/00001KV5MRH6D/item.md +++ b/.yoi/tickets/00001KV5MRH6D/item.md @@ -2,7 +2,7 @@ title: 'Panel 起動遅延の待ち要因を E2E 計測で特定し改善する' state: 'inprogress' created_at: '2026-06-15T12:40:33Z' -updated_at: '2026-06-15T14:01:19Z' +updated_at: '2026-06-15T14:20:26Z' assignee: null readiness: 'implementation_ready' risk_flags: ['panel', 'tui', 'e2e', 'latency', 'runtime-observation'] diff --git a/.yoi/tickets/00001KV5MRH6D/thread.md b/.yoi/tickets/00001KV5MRH6D/thread.md index d95dd855..7b116f2d 100644 --- a/.yoi/tickets/00001KV5MRH6D/thread.md +++ b/.yoi/tickets/00001KV5MRH6D/thread.md @@ -93,4 +93,39 @@ Critical risks / reviewer focus: Routing decision と accepted implementation/evidence plan を記録済み。blocking relation / unresolved OrchestrationPlan blocker はなく、Panel startup latency work は同時に開始する Plugin resolver work と主対象が異なるため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。 +--- + + + +## Implementation report + +### E2E measurements (real `target/debug/yoi` + PTY) + +| Scenario | Before | After | Budget | Notes | +| --- | ---: | ---: | ---: | --- | +| Fixture first visible panel render (`panel_full_ready_has_separate_startup_budget`) | 20.342 ms | 20.384 ms | 1500 ms | Warm fixture did not reproduce 7 s latency; after path keeps first draw separate from reload work. | +| Fixture full-ready rows (`panel_full_ready_has_separate_startup_budget`) | 120.576 ms | 120.552 ms | 5000 ms | Separate metric; fixture full-ready remains well below budget after the first-frame deferral. | +| Held-reload ordering (`panel_first_visible_render_arrives_before_background_reload`) | `background_task_started` before `panel_ready` | `panel_ready` before held `background_task_started`; first visible 20.396 ms | 1500 ms | Guarantees initial visible frame is not blocked by reload/observation. | +| Pre-change ad-hoc Panel run from existing fixture artifact | `background_task_started@76 ms`, `panel_ready@80 ms`, full rows `@182 ms` | latest held/full tests above | n/a | Used to identify wait ordering; fixture still did not reproduce live 7 s. | + +### Wait points identified + +- Synchronous before first draw: CLI/process startup, `run_panel` workspace/cwd setup, `load_app` construction of an empty/loading `WorkspacePanelViewModel`, raw-mode/bracketed-paste/alternate-screen/mouse setup, and the first `terminal.draw`. +- Previously scheduled before first draw: initial Panel reload/observation task (`PendingReload::start(Ensure { ... })`), which can scan Tickets/Pods/orchestrator state and perform socket/status probing before fixture rows are fully ready. +- Background/full-ready after first draw: `load_multi_pod_snapshot`, Ticket list/detail loading, Pod metadata/status checks, orchestrator lifecycle observation, row selection/re-render, and background diagnostics. + +### Changes + +- Added PTY E2E startup coverage for `yoi panel`: + - `panel_first_visible_render_arrives_before_background_reload` asserts `panel_ready` arrives within 1500 ms and before the held reload task starts. + - `panel_full_ready_has_separate_startup_budget` asserts first visible render within 1500 ms and full fixture rows within 5 s as a separate metric. +- Deferred the initial Panel reload start until after the first loading frame is drawn, preserving later background reload correctness. +- Kept Panel/terminal mouse capture to SGR + normal tracking (`?1006h` + `?1000h`) and avoided drag-capture (`?1002h`/`?1003h`) so existing PTY tests can confirm no drag-capture regression. + +### Guaranteed scope / residual gaps + +- Guaranteed by E2E fixture: real binary, PTY, first visible frame budget, held-reload ordering, separate full-ready row budget, no provider/network/secret dependency. +- Residual live/manual gap: the reported ~7 s live Panel startup did not reproduce in this fixture. This change prevents initial reload/observation from blocking or contending with the first visible frame, but live-terminal confirmation is still needed if the remaining cause is workspace-specific (for example a large real Ticket/Pod set or slow live socket/status probe). + + --- diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 6210dc00..0d0926ef 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -131,11 +131,7 @@ pub(crate) async fn run( let mut pending_reload = PendingReload::default(); let mut pending_queue_attention_notice = PendingQueueAttentionNotice::default(); - if let Some(mode) = app.enter_reload.take() { - if pending_reload.start(mode) { - app.refreshing = true; - } - } + let mut deferred_enter_reload = app.enter_reload.take(); let mut next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; #[cfg(feature = "e2e-test")] let mut emitted_panel_ready = false; @@ -161,6 +157,12 @@ pub(crate) async fn run( app.emit_rows_rendered(); } + if let Some(mode) = deferred_enter_reload.take() { + if pending_reload.start(mode) { + app.refreshing = true; + } + } + let now = Instant::now(); if now >= next_poll { pending_reload.start(OrchestratorLifecycleMode::Observe); diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index 93e16b15..e1907e7e 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -34,10 +34,9 @@ use crate::{multi_pod, picker, spawn, ui}; type FullscreenTerminal = Terminal>; -/// Enable SGR coordinates plus button-event tracking for Yoi-owned drag text -/// selection in the single-Pod transcript. This intentionally opts out of -/// terminal-native selection while the alternate screen is active, but still -/// avoids all-motion tracking (`?1003h`). +/// Enable SGR coordinates plus normal mouse tracking. This captures clicks, +/// releases, and wheel events without drag-capture modes (`?1002h`/`?1003h`) +/// so terminal-native drag selection remains available during startup. #[derive(Debug, Clone, Copy)] struct EnableSinglePodMouseCapture; @@ -45,8 +44,31 @@ impl Command for EnableSinglePodMouseCapture { fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { // 1006: SGR extended coordinates used by crossterm's parser // 1000: normal mouse tracking (button presses/releases and wheel) - // 1002: button-event tracking (drag reports while a button is held) - f.write_str("\x1B[?1006h\x1B[?1000h\x1B[?1002h") + f.write_str("\x1B[?1006h\x1B[?1000h") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Ok(()) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + +/// Enable Panel mouse input without drag tracking. The Panel only needs button +/// presses/releases and wheel events; enabling `?1002h` can make terminal drag +/// selection look captured and is intentionally avoided for Panel startup. +#[derive(Debug, Clone, Copy)] +struct EnablePanelMouseCapture; + +impl Command for EnablePanelMouseCapture { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + // 1006: SGR extended coordinates used by crossterm's parser + // 1000: normal mouse tracking (button presses/releases and wheel) + f.write_str("\x1B[?1006h\x1B[?1000h") } #[cfg(windows)] @@ -263,7 +285,7 @@ pub(crate) async fn run_panel( runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { let mut app = multi_pod::load_app(runtime_command.clone()).await?; - let mut terminal = enter_fullscreen()?; + let mut terminal = enter_panel_fullscreen()?; loop { match multi_pod::run(&mut terminal, &mut app).await? { @@ -340,6 +362,15 @@ fn enter_fullscreen() -> Result> Ok(Terminal::new(backend)?) } +fn enter_panel_fullscreen() -> Result> { + let mut stdout = io::stdout(); + // Panel needs clicks and wheel input only; do not capture drag motion before + // the first visible frame. + execute!(stdout, EnterAlternateScreen, EnablePanelMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + Ok(Terminal::new(backend)?) +} + fn enter_fullscreen_existing( terminal: &mut FullscreenTerminal, ) -> Result<(), Box> { @@ -1206,12 +1237,12 @@ mod tests { use protocol::{Event, RewindTarget, RewindTargetId, Segment}; #[test] - fn single_pod_mouse_capture_enables_drag_without_all_motion() { + fn single_pod_mouse_capture_avoids_drag_and_all_motion_modes() { let mut ansi = String::new(); Command::write_ansi(&EnableSinglePodMouseCapture, &mut ansi).unwrap(); assert!(ansi.contains("?1000h")); - assert!(ansi.contains("?1002h")); + assert!(!ansi.contains("?1002h")); assert!(ansi.contains("?1006h")); assert!(!ansi.contains("?1003h")); } diff --git a/tests/e2e/tests/panel.rs b/tests/e2e/tests/panel.rs index 7a15208a..7990e9f6 100644 --- a/tests/e2e/tests/panel.rs +++ b/tests/e2e/tests/panel.rs @@ -1,9 +1,131 @@ -use std::time::Duration; +use std::time::{Duration, Instant}; + +const FIRST_VISIBLE_RENDER_BUDGET: Duration = Duration::from_millis(1500); +const FULL_READY_BUDGET: Duration = Duration::from_secs(5); use yoi_e2e::{ FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, RenderedPanelRow, yoi_binary, }; +#[test] +fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Result<()> { + let binary = yoi_binary()?; + let fixture = FixtureWorkspace::new(&binary)?; + assert_fixture_paths_are_isolated(&fixture); + + let started = Instant::now(); + let mut panel = + PanelHarness::spawn(fixture.panel_config_holding_background_task(binary, "reload"))?; + let remaining = FIRST_VISIBLE_RENDER_BUDGET + .checked_sub(started.elapsed()) + .unwrap_or_else(|| Duration::from_millis(0)); + panel.wait_for("first visible panel render", remaining, |event| { + event.event == "panel_ready" + })?; + let first_visible_elapsed = started.elapsed(); + eprintln!( + "panel first visible render: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}", + panel.artifacts().dir.display() + ); + assert!( + first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET, + "first visible render took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", + panel.artifacts().dir.display() + ); + + let events = panel.events()?; + let ready_index = events + .iter() + .position(|event| event.event == "panel_ready") + .expect("panel_ready event should be present"); + assert!( + events[..ready_index] + .iter() + .all(|event| event.event != "background_task_started"), + "initial render must be emitted before reload/background work starts; artifacts at {}", + panel.artifacts().dir.display() + ); + + panel.expect_background_task_pending("reload")?; + let events = panel.events()?; + let reload_started_index = events + .iter() + .position(|event| { + event.event == "background_task_started" + && event.data.get("task").and_then(serde_json::Value::as_str) == Some("reload") + }) + .expect("held reload should start after first visible render"); + assert!( + ready_index < reload_started_index, + "first visible render and reload ordering should remain separate; artifacts at {}", + panel.artifacts().dir.display() + ); + + panel.press(KeyPress::CtrlC)?; + let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; + assert!(status.success(), "panel should exit cleanly with Ctrl+C"); + drop(panel); + assert_fixture_cleanup(fixture.cleanup()?); + Ok(()) +} + +#[test] +fn panel_full_ready_has_separate_startup_budget() -> yoi_e2e::Result<()> { + let binary = yoi_binary()?; + let fixture = FixtureWorkspace::new(&binary)?; + assert_fixture_paths_are_isolated(&fixture); + + let started = Instant::now(); + let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?; + let first_visible_remaining = FIRST_VISIBLE_RENDER_BUDGET + .checked_sub(started.elapsed()) + .unwrap_or_else(|| Duration::from_millis(0)); + panel.wait_for( + "first visible panel render", + first_visible_remaining, + |event| event.event == "panel_ready", + )?; + let first_visible_elapsed = started.elapsed(); + eprintln!( + "panel first visible render: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}", + panel.artifacts().dir.display() + ); + assert!( + first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET, + "first visible render took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", + panel.artifacts().dir.display() + ); + + let full_ready_remaining = FULL_READY_BUDGET + .checked_sub(started.elapsed()) + .unwrap_or_else(|| Duration::from_millis(0)); + panel.wait_for("full ready fixture rows", full_ready_remaining, |event| { + event.event == "rows_rendered" + && event + .data + .get("rows") + .and_then(serde_json::Value::as_array) + .is_some_and(|rows| rows.len() >= 2) + })?; + let full_ready_elapsed = started.elapsed(); + eprintln!( + "panel full ready: {full_ready_elapsed:?} (budget {FULL_READY_BUDGET:?}); artifacts at {}", + panel.artifacts().dir.display() + ); + assert!( + full_ready_elapsed <= FULL_READY_BUDGET, + "full ready took {full_ready_elapsed:?}, budget {FULL_READY_BUDGET:?}; artifacts at {}", + panel.artifacts().dir.display() + ); + + panel.press(KeyPress::CtrlC)?; + let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; + assert!(status.success(), "panel should exit cleanly with Ctrl+C"); + drop(panel); + assert_fixture_cleanup(fixture.cleanup()?); + Ok(()) +} + #[test] fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result<()> { let binary = yoi_binary()?;