229559caaa
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).
198 lines
7.9 KiB
Python
198 lines
7.9 KiB
Python
"""
|
|
Surgical edit script for src/app_controller.py - adds startup timeline
|
|
instrumentation to AppController.
|
|
|
|
Run: uv run python scripts/apply_startup_timeline.py
|
|
"""
|
|
import ast
|
|
import os
|
|
import sys
|
|
|
|
BASE: str = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
TARGET_FILE: str = "src/app_controller.py"
|
|
EOL: str = "\r\n"
|
|
|
|
|
|
def read_lines(path: str) -> list[str]:
|
|
with open(path, "r", encoding="utf-8", newline="") as f:
|
|
return f.read().splitlines(keepends=True)
|
|
|
|
|
|
def write_lines(path: str, lines: list[str]) -> None:
|
|
with open(path, "w", encoding="utf-8", newline="") as f:
|
|
f.writelines(lines)
|
|
|
|
|
|
def find_init(tree: ast.Module) -> ast.FunctionDef:
|
|
for node in tree.body:
|
|
if isinstance(node, ast.ClassDef) and node.name == "AppController":
|
|
for item in node.body:
|
|
if isinstance(item, ast.FunctionDef) and item.name == "__init__":
|
|
return item
|
|
raise RuntimeError("AppController.__init__ not found")
|
|
|
|
|
|
def patch_def_signature(lines: list[str], init_fn: ast.FunctionDef) -> None:
|
|
idx = init_fn.lineno - 1
|
|
line = lines[idx]
|
|
if "log_to_stderr" in line:
|
|
return
|
|
new_line = line.replace("def __init__(self):", "def __init__(self, log_to_stderr: bool = True):")
|
|
if new_line == line:
|
|
raise RuntimeError(f"Could not patch def line: {line!r}")
|
|
lines[idx] = new_line
|
|
print(f" Patched def signature at line {init_fn.lineno}")
|
|
|
|
|
|
def insert_timeline_block(lines: list[str]) -> None:
|
|
for i, line in enumerate(lines):
|
|
if line.strip() == '"""' and i + 1 < len(lines) and "# --- Locks ---" in lines[i + 1]:
|
|
block_lines = [
|
|
' # --- Startup timeline (startup_speedup_20260606) ---' + EOL,
|
|
' # Captured at the very start of __init__ so init_start_ts represents' + EOL,
|
|
' # the true cold-start entry point. first_frame_ts and warmup_done_ts' + EOL,
|
|
' # are filled in later as events occur.' + EOL,
|
|
' self._init_start_ts: float = time.time()' + EOL,
|
|
' self._warmup_done_ts: Optional[float] = None' + EOL,
|
|
' self._first_frame_ts: Optional[float] = None' + EOL,
|
|
]
|
|
lines[i + 1:i + 1] = block_lines
|
|
print(f" Inserted timeline block at line {i + 2}")
|
|
return
|
|
raise RuntimeError("Could not find docstring-end + Locks-comment marker")
|
|
|
|
|
|
def patch_warmup_block(lines: list[str]) -> None:
|
|
old = [
|
|
' # --- Shared background pool + proactive warmup (startup_speedup_20260606) ---' + EOL,
|
|
' self._io_pool = make_io_pool()' + EOL,
|
|
' self._warmup = WarmupManager(self._io_pool)' + EOL,
|
|
' self._warmup.submit(self._compute_warmup_list())' + EOL,
|
|
]
|
|
new = [
|
|
' # --- Shared background pool + proactive warmup (startup_speedup_20260606) ---' + EOL,
|
|
' self._io_pool = make_io_pool()' + EOL,
|
|
' self._warmup = WarmupManager(self._io_pool, log_to_stderr=log_to_stderr)' + EOL,
|
|
' # Hook warmup completion to stamp warmup_done_ts for startup_timeline().' + EOL,
|
|
' self._warmup.on_complete(self._on_warmup_complete_for_timeline)' + EOL,
|
|
' self._warmup.submit(self._compute_warmup_list())' + EOL,
|
|
]
|
|
for i in range(len(lines) - len(old) + 1):
|
|
if lines[i:i + len(old)] == old:
|
|
lines[i:i + len(old)] = new
|
|
print(f" Replaced warmup block at lines {i + 1}-{i + len(old)}")
|
|
return
|
|
raise RuntimeError("Could not find warmup block to replace")
|
|
|
|
|
|
NEW_METHODS_TEMPLATE = ''' 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
|
|
|
|
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
|
|
|
|
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:
|
|
def insert_new_methods(lines: list[str]) -> None:
|
|
"""Insert new methods right after the last line of __init__ (`self._init_actions()`)."""
|
|
needle = ' self._init_actions()' + EOL
|
|
for i, line in enumerate(lines):
|
|
if line == needle:
|
|
# Insert AFTER this line. The next line is blank, then the next method.
|
|
new_lines = [l + EOL for l in NEW_METHODS_TEMPLATE.split("\n") if l]
|
|
insert_at = i + 1
|
|
lines[insert_at:insert_at] = new_lines
|
|
print(f" Inserted {len(new_lines)} new method lines at line {insert_at + 1}")
|
|
return
|
|
raise RuntimeError("Could not find 'self._init_actions()' to anchor new methods")
|
|
}
|
|
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
|
|
|
|
'''
|
|
|
|
|
|
def insert_new_methods(lines: list[str]) -> None:
|
|
for i, line in enumerate(lines):
|
|
if line.lstrip().startswith("def perf_profiling_enabled"):
|
|
new_lines = [l + EOL for l in NEW_METHODS_TEMPLATE.split("\n") if l]
|
|
lines[i:i] = new_lines
|
|
print(f" Inserted {len(new_lines)} new method lines at line {i + 1}")
|
|
return
|
|
raise RuntimeError("Could not find 'def perf_profiling_enabled' to anchor new methods")
|
|
|
|
|
|
def main() -> None:
|
|
path = os.path.join(BASE, TARGET_FILE)
|
|
lines = read_lines(path)
|
|
code = "".join(lines)
|
|
tree = ast.parse(code)
|
|
init_fn = find_init(tree)
|
|
print(f"Found AppController.__init__ at lines {init_fn.lineno}-{init_fn.end_lineno}")
|
|
patch_def_signature(lines, init_fn)
|
|
insert_timeline_block(lines)
|
|
patch_warmup_block(lines)
|
|
insert_new_methods(lines)
|
|
write_lines(path, lines)
|
|
print(f"\nWrote {len(lines)} lines to {path}")
|
|
with open(path, "rb") as f:
|
|
ast.parse(f.read())
|
|
print(" Syntax OK")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|