merge: panel startup latency e2e
This commit is contained in:
commit
6f99ebedcc
|
|
@ -2,7 +2,7 @@
|
||||||
title: 'Panel 起動遅延の待ち要因を E2E 計測で特定し改善する'
|
title: 'Panel 起動遅延の待ち要因を E2E 計測で特定し改善する'
|
||||||
state: 'inprogress'
|
state: 'inprogress'
|
||||||
created_at: '2026-06-15T12:40:33Z'
|
created_at: '2026-06-15T12:40:33Z'
|
||||||
updated_at: '2026-06-15T14:01:19Z'
|
updated_at: '2026-06-15T14:20:26Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
readiness: 'implementation_ready'
|
readiness: 'implementation_ready'
|
||||||
risk_flags: ['panel', 'tui', 'e2e', 'latency', 'runtime-observation']
|
risk_flags: ['panel', 'tui', 'e2e', 'latency', 'runtime-observation']
|
||||||
|
|
|
||||||
|
|
@ -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 を記録する。
|
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).
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -131,11 +131,7 @@ pub(crate) async fn run(
|
||||||
|
|
||||||
let mut pending_reload = PendingReload::default();
|
let mut pending_reload = PendingReload::default();
|
||||||
let mut pending_queue_attention_notice = PendingQueueAttentionNotice::default();
|
let mut pending_queue_attention_notice = PendingQueueAttentionNotice::default();
|
||||||
if let Some(mode) = app.enter_reload.take() {
|
let mut deferred_enter_reload = app.enter_reload.take();
|
||||||
if pending_reload.start(mode) {
|
|
||||||
app.refreshing = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
|
let mut next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
let mut emitted_panel_ready = false;
|
let mut emitted_panel_ready = false;
|
||||||
|
|
@ -161,6 +157,12 @@ pub(crate) async fn run(
|
||||||
app.emit_rows_rendered();
|
app.emit_rows_rendered();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(mode) = deferred_enter_reload.take() {
|
||||||
|
if pending_reload.start(mode) {
|
||||||
|
app.refreshing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if now >= next_poll {
|
if now >= next_poll {
|
||||||
pending_reload.start(OrchestratorLifecycleMode::Observe);
|
pending_reload.start(OrchestratorLifecycleMode::Observe);
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,9 @@ use crate::{multi_pod, picker, spawn, ui};
|
||||||
|
|
||||||
type FullscreenTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
type FullscreenTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
||||||
|
|
||||||
/// Enable SGR coordinates plus button-event tracking for Yoi-owned drag text
|
/// Enable SGR coordinates plus normal mouse tracking. This captures clicks,
|
||||||
/// selection in the single-Pod transcript. This intentionally opts out of
|
/// releases, and wheel events without drag-capture modes (`?1002h`/`?1003h`)
|
||||||
/// terminal-native selection while the alternate screen is active, but still
|
/// so terminal-native drag selection remains available during startup.
|
||||||
/// avoids all-motion tracking (`?1003h`).
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
struct EnableSinglePodMouseCapture;
|
struct EnableSinglePodMouseCapture;
|
||||||
|
|
||||||
|
|
@ -45,8 +44,31 @@ impl Command for EnableSinglePodMouseCapture {
|
||||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||||
// 1006: SGR extended coordinates used by crossterm's parser
|
// 1006: SGR extended coordinates used by crossterm's parser
|
||||||
// 1000: normal mouse tracking (button presses/releases and wheel)
|
// 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")
|
||||||
f.write_str("\x1B[?1006h\x1B[?1000h\x1B[?1002h")
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[cfg(windows)]
|
||||||
|
|
@ -263,7 +285,7 @@ pub(crate) async fn run_panel(
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut app = multi_pod::load_app(runtime_command.clone()).await?;
|
let mut app = multi_pod::load_app(runtime_command.clone()).await?;
|
||||||
let mut terminal = enter_fullscreen()?;
|
let mut terminal = enter_panel_fullscreen()?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match multi_pod::run(&mut terminal, &mut app).await? {
|
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)?)
|
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(
|
fn enter_fullscreen_existing(
|
||||||
terminal: &mut FullscreenTerminal,
|
terminal: &mut FullscreenTerminal,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
@ -1206,12 +1237,12 @@ mod tests {
|
||||||
use protocol::{Event, RewindTarget, RewindTargetId, Segment};
|
use protocol::{Event, RewindTarget, RewindTargetId, Segment};
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let mut ansi = String::new();
|
||||||
Command::write_ansi(&EnableSinglePodMouseCapture, &mut ansi).unwrap();
|
Command::write_ansi(&EnableSinglePodMouseCapture, &mut ansi).unwrap();
|
||||||
|
|
||||||
assert!(ansi.contains("?1000h"));
|
assert!(ansi.contains("?1000h"));
|
||||||
assert!(ansi.contains("?1002h"));
|
assert!(!ansi.contains("?1002h"));
|
||||||
assert!(ansi.contains("?1006h"));
|
assert!(ansi.contains("?1006h"));
|
||||||
assert!(!ansi.contains("?1003h"));
|
assert!(!ansi.contains("?1003h"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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::{
|
use yoi_e2e::{
|
||||||
FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, RenderedPanelRow, yoi_binary,
|
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]
|
#[test]
|
||||||
fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result<()> {
|
fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result<()> {
|
||||||
let binary = yoi_binary()?;
|
let binary = yoi_binary()?;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user