Skip to content

Commit ef20f44

Browse files
jmoseleyCopilot
andcommitted
Add StartupTimings breakdown to Client::start
Introduce a `StartupTimings` struct that decomposes the CLI spawn + handshake cost into per-phase millisecond fields, so hosts can attribute "time to first token" startup latency to a specific phase instead of reconstructing it from scattered debug lines. Phases captured: - program_resolve_ms: NEW timer around bundled-CLI resolution/extraction (`resolve::copilot_binary_with_extract_dir`), previously untimed and the prime suspect for cold-start cost on Windows. - process_spawn_ms: subprocess `command.spawn()`. - port_wait_ms: TCP port-announcement wait (tcp transport only). - handshake_ms: `verify_protocol_version` connect round-trip. - session_fs_ms / llm_handler_ms: post-handshake provider-registration RPCs. - total_ms: full Client::start wall-clock. Surfacing is non-breaking: timings are stored on ClientInner via a `OnceLock` and exposed through a new `Client::startup_timings()` getter, plus a single structured `debug!` event. `Client::start`'s return type is unchanged. Existing per-phase debug logs are retained. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 95834bd3-b7ac-40bc-8bf4-60102600d41a
1 parent 949de90 commit ef20f44

2 files changed

Lines changed: 163 additions & 11 deletions

File tree

‎rust/src/lib.rs‎

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ pub mod session;
4040
/// Custom session filesystem provider (virtualizable filesystem layer).
4141
pub mod session_fs;
4242
mod session_fs_dispatch;
43+
/// Per-phase timing breakdown for [`Client::start`].
44+
pub mod startup_timings;
4345
/// Event subscription handles returned by `subscribe()` methods.
4446
pub mod subscription;
4547
/// Typed tool definition framework and dispatch router.
@@ -106,6 +108,7 @@ pub use types::*;
106108

107109
mod sdk_protocol_version;
108110
pub use sdk_protocol_version::{SDK_PROTOCOL_VERSION, get_sdk_protocol_version};
111+
pub use startup_timings::StartupTimings;
109112
pub use subscription::{EventSubscription, LifecycleSubscription};
110113

111114
/// Minimum protocol version this SDK can communicate with.
@@ -1007,6 +1010,10 @@ struct ClientInner {
10071010
/// SDK [`ClientMode`] captured at start time. Drives empty-mode safe
10081011
/// defaults inside `create_session` / `resume_session`.
10091012
pub(crate) mode: ClientMode,
1013+
/// Per-phase startup timing breakdown, populated once at the end of
1014+
/// [`Client::start`]. Empty for clients built via [`Client::from_streams`]
1015+
/// or [`Client::from_transport`] directly.
1016+
startup_timings: OnceLock<StartupTimings>,
10101017
}
10111018

10121019
impl Client {
@@ -1024,6 +1031,7 @@ impl Client {
10241031
/// backend.
10251032
pub async fn start(options: ClientOptions) -> Result<Self> {
10261033
let start_time = Instant::now();
1034+
let mut timings = StartupTimings::default();
10271035
let mut options = options;
10281036
if matches!(options.transport, Transport::Default) {
10291037
options.transport = resolve_default_transport(&options)?;
@@ -1119,9 +1127,16 @@ impl Client {
11191127
path.clone()
11201128
}
11211129
CliProgram::Resolve => {
1130+
let resolve_start = Instant::now();
11221131
let resolved = resolve::copilot_binary_with_extract_dir(
11231132
options.bundled_cli_extract_dir.as_deref(),
11241133
)?;
1134+
let resolve_elapsed = resolve_start.elapsed();
1135+
timings.program_resolve_ms = Some(StartupTimings::millis(resolve_elapsed));
1136+
debug!(
1137+
elapsed_ms = resolve_elapsed.as_millis(),
1138+
"Client::start CLI program resolution complete"
1139+
);
11251140
info!(path = %resolved.display(), "resolved copilot CLI");
11261141
#[cfg(windows)]
11271142
{
@@ -1183,8 +1198,10 @@ impl Client {
11831198
port,
11841199
connection_token: _,
11851200
} => {
1186-
let (mut child, actual_port) =
1201+
let (mut child, actual_port, spawn_elapsed, port_wait_elapsed) =
11871202
Self::spawn_tcp(&program, &options, &working_directory, port).await?;
1203+
timings.process_spawn_ms = Some(StartupTimings::millis(spawn_elapsed));
1204+
timings.port_wait_ms = Some(StartupTimings::millis(port_wait_elapsed));
11881205
let connect_start = Instant::now();
11891206
let stream = TcpStream::connect(("127.0.0.1", actual_port)).await?;
11901207
debug!(
@@ -1209,7 +1226,9 @@ impl Client {
12091226
)?
12101227
}
12111228
Transport::Stdio => {
1212-
let mut child = Self::spawn_stdio(&program, &options, &working_directory)?;
1229+
let (mut child, spawn_elapsed) =
1230+
Self::spawn_stdio(&program, &options, &working_directory)?;
1231+
timings.process_spawn_ms = Some(StartupTimings::millis(spawn_elapsed));
12131232
let stdin = child.stdin.take().expect("stdin is piped");
12141233
let stdout = child.stdout.take().expect("stdout is piped");
12151234
Self::drain_stderr(&mut child);
@@ -1294,7 +1313,9 @@ impl Client {
12941313
elapsed_ms = start_time.elapsed().as_millis(),
12951314
"Client::start transport setup complete"
12961315
);
1316+
let handshake_start = Instant::now();
12971317
client.verify_protocol_version().await?;
1318+
timings.handshake_ms = Some(StartupTimings::millis(handshake_start.elapsed()));
12981319
debug!(
12991320
elapsed_ms = start_time.elapsed().as_millis(),
13001321
"Client::start protocol verification complete"
@@ -1313,8 +1334,10 @@ impl Client {
13131334
session_state_path: cfg.session_state_path,
13141335
};
13151336
client.rpc().session_fs().set_provider(request).await?;
1337+
let session_fs_elapsed = session_fs_start.elapsed();
1338+
timings.session_fs_ms = Some(StartupTimings::millis(session_fs_elapsed));
13161339
debug!(
1317-
elapsed_ms = session_fs_start.elapsed().as_millis(),
1340+
elapsed_ms = session_fs_elapsed.as_millis(),
13181341
"Client::start session filesystem setup complete"
13191342
);
13201343
}
@@ -1334,11 +1357,28 @@ impl Client {
13341357
client.inner.on_github_telemetry.clone(),
13351358
);
13361359
client.rpc().llm_inference().set_provider().await?;
1360+
let llm_inference_elapsed = llm_inference_start.elapsed();
1361+
timings.llm_handler_ms = Some(StartupTimings::millis(llm_inference_elapsed));
13371362
debug!(
1338-
elapsed_ms = llm_inference_start.elapsed().as_millis(),
1363+
elapsed_ms = llm_inference_elapsed.as_millis(),
13391364
"Client::start Copilot request handler registration complete"
13401365
);
13411366
}
1367+
timings.total_ms = Some(StartupTimings::millis(start_time.elapsed()));
1368+
// Single structured event with the full per-phase breakdown, so hosts
1369+
// can attribute startup latency to a phase without stitching together
1370+
// the individual debug lines above.
1371+
debug!(
1372+
program_resolve_ms = ?timings.program_resolve_ms,
1373+
process_spawn_ms = ?timings.process_spawn_ms,
1374+
port_wait_ms = ?timings.port_wait_ms,
1375+
handshake_ms = ?timings.handshake_ms,
1376+
session_fs_ms = ?timings.session_fs_ms,
1377+
llm_handler_ms = ?timings.llm_handler_ms,
1378+
total_ms = ?timings.total_ms,
1379+
"Client::start timings"
1380+
);
1381+
let _ = client.inner.startup_timings.set(timings);
13421382
debug!(
13431383
elapsed_ms = start_time.elapsed().as_millis(),
13441384
"Client::start complete"
@@ -1507,6 +1547,7 @@ impl Client {
15071547
on_get_trace_context,
15081548
effective_connection_token,
15091549
mode,
1550+
startup_timings: OnceLock::new(),
15101551
}),
15111552
};
15121553
client.spawn_lifecycle_dispatcher();
@@ -1683,7 +1724,7 @@ impl Client {
16831724
program: &Path,
16841725
options: &ClientOptions,
16851726
working_directory: &Path,
1686-
) -> Result<Child> {
1727+
) -> Result<(Child, Duration)> {
16871728
info!(cwd = ?working_directory, program = %program.display(), "spawning copilot CLI (stdio)");
16881729
let mut command = Self::build_command(program, options, working_directory);
16891730
command
@@ -1696,19 +1737,20 @@ impl Client {
16961737
.stdin(Stdio::piped());
16971738
let spawn_start = Instant::now();
16981739
let child = command.spawn()?;
1740+
let spawn_elapsed = spawn_start.elapsed();
16991741
debug!(
1700-
elapsed_ms = spawn_start.elapsed().as_millis(),
1742+
elapsed_ms = spawn_elapsed.as_millis(),
17011743
"Client::spawn_stdio subprocess spawned"
17021744
);
1703-
Ok(child)
1745+
Ok((child, spawn_elapsed))
17041746
}
17051747

17061748
async fn spawn_tcp(
17071749
program: &Path,
17081750
options: &ClientOptions,
17091751
working_directory: &Path,
17101752
port: u16,
1711-
) -> Result<(Child, u16)> {
1753+
) -> Result<(Child, u16, Duration, Duration)> {
17121754
info!(cwd = ?working_directory, program = %program.display(), port = %port, "spawning copilot CLI (tcp)");
17131755
let mut command = Self::build_command(program, options, working_directory);
17141756
command
@@ -1721,8 +1763,9 @@ impl Client {
17211763
.stdin(Stdio::null());
17221764
let spawn_start = Instant::now();
17231765
let mut child = command.spawn()?;
1766+
let spawn_elapsed = spawn_start.elapsed();
17241767
debug!(
1725-
elapsed_ms = spawn_start.elapsed().as_millis(),
1768+
elapsed_ms = spawn_elapsed.as_millis(),
17261769
"Client::spawn_tcp subprocess spawned"
17271770
);
17281771
let stdout = child.stdout.take().expect("stdout is piped");
@@ -1759,13 +1802,14 @@ impl Client {
17591802
.map_err(|_| Error::from(ErrorKind::Protocol(ProtocolErrorKind::CliStartupTimeout)))?
17601803
.map_err(|_| Error::from(ErrorKind::Protocol(ProtocolErrorKind::CliStartupFailed)))?;
17611804

1805+
let port_wait_elapsed = port_wait_start.elapsed();
17621806
debug!(
1763-
elapsed_ms = port_wait_start.elapsed().as_millis(),
1807+
elapsed_ms = port_wait_elapsed.as_millis(),
17641808
port = actual_port,
17651809
"Client::spawn_tcp TCP port wait complete"
17661810
);
17671811
info!(port = %actual_port, "CLI server listening");
1768-
Ok((child, actual_port))
1812+
Ok((child, actual_port, spawn_elapsed, port_wait_elapsed))
17691813
}
17701814

17711815
fn drain_stderr(child: &mut Child) {
@@ -1942,6 +1986,16 @@ impl Client {
19421986
self.inner.negotiated_protocol_version.get().copied()
19431987
}
19441988

1989+
/// Returns the per-phase [`StartupTimings`] breakdown captured during
1990+
/// [`start`](Self::start), if available.
1991+
///
1992+
/// Returns `None` for clients created via
1993+
/// [`from_streams`](Self::from_streams), which bypasses the timed startup
1994+
/// sequence.
1995+
pub fn startup_timings(&self) -> Option<StartupTimings> {
1996+
self.inner.startup_timings.get().cloned()
1997+
}
1998+
19451999
/// Verify the CLI server's protocol version is within the supported range.
19462000
///
19472001
/// Called automatically by [`start`](Self::start). Call manually after
@@ -3112,6 +3166,7 @@ mod tests {
31123166
on_get_trace_context: None,
31133167
effective_connection_token: None,
31143168
mode: ClientMode::default(),
3169+
startup_timings: OnceLock::new(),
31153170
}),
31163171
}
31173172
}

‎rust/src/startup_timings.rs‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//! Per-phase timing breakdown for [`Client::start`](crate::Client::start).
2+
//!
3+
//! `Client::start` performs several sequential phases between "spawn the CLI"
4+
//! and "client is ready to create sessions": resolving (and possibly
5+
//! extracting) the CLI binary, spawning the subprocess, waiting for the TCP
6+
//! port announcement, the `connect` protocol handshake, and the optional
7+
//! `sessionFs.setProvider` / `llmInference.setProvider` registration RPCs.
8+
//!
9+
//! Each phase is already measured internally with an [`Instant`] and logged at
10+
//! `debug`. [`StartupTimings`] aggregates those durations into a single value
11+
//! so a host can attribute total startup latency ("time to first token"
12+
//! groundwork) to a specific phase — e.g. separating "process exec cost" from
13+
//! "handshake/negotiation cost" — instead of reconstructing it from scattered
14+
//! log lines.
15+
//!
16+
//! Retrieve it after start via
17+
//! [`Client::startup_timings`](crate::Client::startup_timings).
18+
//!
19+
//! [`Instant`]: std::time::Instant
20+
21+
use std::time::Duration;
22+
23+
/// Millisecond breakdown of the phases of [`Client::start`](crate::Client::start).
24+
///
25+
/// Every field is `Option<u64>` because a phase is only timed when it actually
26+
/// runs: `program_resolve_ms` is `None` when the caller supplies an explicit
27+
/// CLI path (no resolution/extraction), `port_wait_ms` is `Some` only for the
28+
/// TCP transport, and `session_fs_ms` / `llm_handler_ms` are `Some` only when
29+
/// the corresponding option is configured. `process_spawn_ms` is `None` for
30+
/// transports that do not spawn a subprocess (external server, in-process
31+
/// FFI runtime).
32+
///
33+
/// Durations are whole milliseconds, matching the existing `elapsed_ms`
34+
/// tracing fields.
35+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
36+
#[non_exhaustive]
37+
pub struct StartupTimings {
38+
/// Time spent in `resolve::copilot_binary_with_extract_dir` locating (and,
39+
/// for a bundled CLI, extracting) the copilot binary. `None` when the
40+
/// caller passes an explicit [`CliProgram::Path`](crate::CliProgram::Path).
41+
pub program_resolve_ms: Option<u64>,
42+
/// Time spent spawning the CLI subprocess (`command.spawn()`). `None` for
43+
/// the external-server and in-process transports, which do not spawn a
44+
/// child.
45+
pub process_spawn_ms: Option<u64>,
46+
/// Time spent waiting for the TCP server to announce its listening port on
47+
/// stdout. `Some` only for the TCP transport.
48+
pub port_wait_ms: Option<u64>,
49+
/// Time spent on the `connect` protocol handshake in
50+
/// [`Client::verify_protocol_version`](crate::Client::verify_protocol_version),
51+
/// including the fallback to the legacy `ping` RPC.
52+
pub handshake_ms: Option<u64>,
53+
/// Time spent registering the filesystem provider via
54+
/// `sessionFs.setProvider`. `Some` only when
55+
/// [`ClientOptions::session_fs`](crate::ClientOptions::session_fs) is set.
56+
pub session_fs_ms: Option<u64>,
57+
/// Time spent registering the LLM inference provider via
58+
/// `llmInference.setProvider`. `Some` only when
59+
/// [`ClientOptions::request_handler`](crate::ClientOptions::request_handler)
60+
/// is set.
61+
pub llm_handler_ms: Option<u64>,
62+
/// Total wall-clock time for [`Client::start`](crate::Client::start), from
63+
/// entry to the client being ready. Always present.
64+
pub total_ms: Option<u64>,
65+
}
66+
67+
impl StartupTimings {
68+
/// Whole milliseconds of `duration`, saturating at [`u64::MAX`].
69+
pub(crate) fn millis(duration: Duration) -> u64 {
70+
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
71+
}
72+
}
73+
74+
#[cfg(test)]
75+
mod tests {
76+
use super::*;
77+
78+
#[test]
79+
fn millis_truncates_to_whole_milliseconds() {
80+
assert_eq!(StartupTimings::millis(Duration::from_micros(1_999)), 1);
81+
assert_eq!(StartupTimings::millis(Duration::from_millis(250)), 250);
82+
assert_eq!(StartupTimings::millis(Duration::ZERO), 0);
83+
}
84+
85+
#[test]
86+
fn default_leaves_every_phase_unset() {
87+
let timings = StartupTimings::default();
88+
assert_eq!(timings, StartupTimings::default());
89+
assert!(timings.program_resolve_ms.is_none());
90+
assert!(timings.process_spawn_ms.is_none());
91+
assert!(timings.port_wait_ms.is_none());
92+
assert!(timings.handshake_ms.is_none());
93+
assert!(timings.session_fs_ms.is_none());
94+
assert!(timings.llm_handler_ms.is_none());
95+
assert!(timings.total_ms.is_none());
96+
}
97+
}

0 commit comments

Comments
 (0)