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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user