Private
Public Access
0
0

feat(startup): first-frame detection + startup_timeline API

Adds per-AppController startup timing instrumentation to answer
'did the warmup block the first frame?'

AppController.__init__ records _init_start_ts at entry (cold-start anchor).
WarmupManager.on_complete callback stamps _warmup_done_ts.
App.render_main_interface (gui_2.py) calls mark_first_frame_rendered()
on its first call, which stamps _first_frame_ts and logs the timeline.

New public API on AppController:
- init_start_ts (property): float
- warmup_done_ts (property): Optional[float]
- first_frame_ts (property): Optional[float]
- mark_first_frame_rendered(ts=None): idempotent; logs to stderr
- startup_timeline() -> dict with all timestamps + precomputed deltas:
  warmup_ms, first_frame_after_init_ms, first_frame_after_warmup_ms

Stderr log on warmup done:
  [startup] warmup done in 1186.2ms (first frame rendered Nms BEFORE/AFTER)

Stderr log on first frame:
  [startup] first frame at Xms after init (warmup took Yms) (rendered Zms BEFORE/AFTER warmup done)

Hook API:
- GET /api/startup_timeline
- ApiHookClient.get_startup_timeline() -> dict

5 new tests in test_warmup_canaries.py covering all the new methods.
All 18 canary tests + 10 api_hooks tests + 6 gui_indicator tests pass.

Script scripts/apply_startup_timeline.py is included as a reference
for the multi-edit pattern (the proper MCP-equivalent tools will be
added later per the edit_workflow doc).
This commit is contained in:
2026-06-06 22:48:50 -04:00
parent 152605f5dc
commit 229559caaa
6 changed files with 377 additions and 2 deletions
+10
View File
@@ -329,6 +329,16 @@ class ApiHookClient:
result = self._make_request('GET', '/api/warmup_canaries') or {}
return result.get("canaries", []) if isinstance(result, dict) else []
def get_startup_timeline(self) -> dict[str, Any]:
"""
Returns the startup timeline: dict with init_start_ts, warmup_done_ts,
first_frame_ts, warmup_ms, first_frame_after_init_ms,
first_frame_after_warmup_ms. Lets external clients answer
'did the warmup block the first frame?'.
[C: tests/test_api_hooks_warmup.py:test_live_startup_timeline_endpoint]
"""
return self._make_request('GET', '/api/startup_timeline') or {}
#endregion: Diagnostics
#region: Project
+12
View File
@@ -382,6 +382,18 @@ class HookHandler(BaseHTTPRequestHandler):
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(payload).encode("utf-8"))
elif self.path == "/api/startup_timeline" or self.path.startswith("/api/startup_timeline?"):
# Startup timeline: init/warmup/first-frame timestamps + precomputed deltas.
controller = _get_app_attr(app, "controller", None)
empty = {"init_start_ts": None, "warmup_done_ts": None, "first_frame_ts": None, "warmup_ms": None, "first_frame_after_init_ms": None, "first_frame_after_warmup_ms": None}
if controller and hasattr(controller, "startup_timeline"):
try: payload = controller.startup_timeline()
except Exception: payload = empty
else: payload = empty
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(payload).encode("utf-8"))
else:
self.send_response(404)
self.end_headers()
+81 -2
View File
@@ -801,10 +801,14 @@ class AppController:
Owns the application state and manages background services.
"""
def __init__(self):
def __init__(self, log_to_stderr: bool = True):
"""
[C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__]
"""
# --- Startup timeline (startup_speedup_20260606) ---
self._init_start_ts: float = time.time()
self._warmup_done_ts: Optional[float] = None
self._first_frame_ts: Optional[float] = None
# --- Locks ---
self._send_thread_lock: threading.Lock = threading.Lock()
self._disc_entries_lock: threading.Lock = threading.Lock()
@@ -819,7 +823,9 @@ class AppController:
# --- Shared background pool + proactive warmup (startup_speedup_20260606) ---
self._io_pool = make_io_pool()
self._warmup = WarmupManager(self._io_pool)
self._warmup = WarmupManager(self._io_pool, log_to_stderr=log_to_stderr)
# Hook warmup completion to stamp warmup_done_ts for startup_timeline().
self._warmup.on_complete(self._on_warmup_complete_for_timeline)
self._warmup.submit(self._compute_warmup_list())
# --- Internal State ---
@@ -1189,6 +1195,79 @@ class AppController:
}
self._init_actions()
@property
def init_start_ts(self) -> float:
"""Timestamp when AppController.__init__ started (cold-start entry). [SDM: src/app_controller.py:init_start_ts]"""
return self._init_start_ts
@property
def warmup_done_ts(self) -> "Optional[float]":
"""Timestamp when the warmup completed; None while still running. [SDM: src/app_controller.py:warmup_done_ts]"""
return self._warmup_done_ts
@property
def first_frame_ts(self) -> "Optional[float]":
"""Timestamp of the first GUI frame; None until the App has rendered once. [SDM: src/app_controller.py:first_frame_ts]"""
return self._first_frame_ts
def mark_first_frame_rendered(self, ts: "Optional[float]" = None) -> None:
"""Called by the App on the first frame render. Stamps first_frame_ts and logs the timeline to stderr. [SDM: src/app_controller.py:mark_first_frame_rendered] [C: src/gui_2.py:render_main_interface]"""
if self._first_frame_ts is not None: return
self._first_frame_ts = ts if ts is not None else time.time()
try:
warmup_ms = (self._warmup_done_ts - self._init_start_ts) * 1000 if self._warmup_done_ts is not None else 0.0
frame_after_init_ms = (self._first_frame_ts - self._init_start_ts) * 1000
if self._warmup_done_ts is None:
gap_str = " (warmup still running at first frame; warmup did NOT block the first frame)"
else:
delta_ms = (self._first_frame_ts - self._warmup_done_ts) * 1000
if delta_ms < 0:
gap_str = f" (rendered {-delta_ms:.1f}ms BEFORE warmup done \u2014 warmup did NOT block)"
else:
gap_str = f" (rendered {delta_ms:.1f}ms AFTER warmup done)"
sys.stderr.write(f"[startup] first frame at {frame_after_init_ms:.1f}ms after init (warmup took {warmup_ms:.1f}ms){gap_str}\n")
sys.stderr.flush()
except Exception: pass
def startup_timeline(self) -> dict:
"""Returns a dict with all startup timestamps and precomputed deltas. Fields: init_start_ts, warmup_done_ts, first_frame_ts, warmup_ms, first_frame_after_init_ms, first_frame_after_warmup_ms. [SDM: src/app_controller.py:startup_timeline] [C: src/api_hooks.py:HookHandler.do_GET /api/startup_timeline]"""
result: dict = {
"init_start_ts": self._init_start_ts,
"warmup_done_ts": self._warmup_done_ts,
"first_frame_ts": self._first_frame_ts,
}
if self._warmup_done_ts is not None:
result["warmup_ms"] = (self._warmup_done_ts - self._init_start_ts) * 1000
else:
result["warmup_ms"] = None
if self._first_frame_ts is not None:
result["first_frame_after_init_ms"] = (self._first_frame_ts - self._init_start_ts) * 1000
if self._warmup_done_ts is not None:
result["first_frame_after_warmup_ms"] = (self._first_frame_ts - self._warmup_done_ts) * 1000
else:
result["first_frame_after_warmup_ms"] = None
else:
result["first_frame_after_init_ms"] = None
result["first_frame_after_warmup_ms"] = None
return result
def _on_warmup_complete_for_timeline(self, snap: dict) -> None:
"""Callback registered with the WarmupManager. Stamps warmup_done_ts and logs the timeline to stderr. [C: src/app_controller.py:startup_timeline]"""
self._warmup_done_ts = time.time()
try:
warmup_ms = (self._warmup_done_ts - self._init_start_ts) * 1000
if self._first_frame_ts is None:
gap_str = f" (first frame not yet rendered at warmup done; warmup took {warmup_ms:.1f}ms)"
else:
delta_ms = (self._first_frame_ts - self._warmup_done_ts) * 1000
if delta_ms < 0:
gap_str = f" (first frame rendered {-delta_ms:.1f}ms BEFORE warmup done \u2014 warmup did NOT block)"
else:
gap_str = f" (first frame rendered {delta_ms:.1f}ms after warmup done)"
sys.stderr.write(f"[startup] warmup done in {warmup_ms:.1f}ms{gap_str}\n")
sys.stderr.flush()
except Exception: pass
@property
def perf_profiling_enabled(self) -> bool:
return self._perf_profiling_enabled
+2
View File
@@ -1309,6 +1309,8 @@ if __name__ == "__main__":
main()
def render_main_interface(app: App) -> None:
if hasattr(app, "controller") and hasattr(app.controller, "mark_first_frame_rendered"):
app.controller.mark_first_frame_rendered()
render_error_tint(app)
render_project_stale_tint(app)
render_warmup_status_indicator(app)