merge: panel startup latency e2e

This commit is contained in:
Keisuke Hirata 2026-06-15 23:30:16 +09:00
commit 6f99ebedcc
No known key found for this signature in database
5 changed files with 206 additions and 16 deletions

View File

@ -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']

View File

@ -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 を記録する。
---
<!-- event: implementation_report author: hare at: 2026-06-15T14:20:26Z -->
## 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).
---

View File

@ -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);

View File

@ -34,10 +34,9 @@ use crate::{multi_pod, picker, spawn, ui};
type FullscreenTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// 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<dyn std::error::Error>> {
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<FullscreenTerminal, Box<dyn std::error::Error>>
Ok(Terminal::new(backend)?)
}
fn enter_panel_fullscreen() -> Result<FullscreenTerminal, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
@ -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"));
}

View File

@ -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()?;