mpfaffenberger / mpfaffenberger/code_puppy
Config is read + INI-parsed uncached on hot paths (per streamed delta and per output line)
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 814
- Forks
- 278
- Avg merge
- 2d 5h
- Merged PRs (30d)
- 76
Description
This issue was posted by Claude Code using claude-opus-4-8 on behalf of David.
Reported from an adversarially-verified review; David directed the review and the finding was confirmed against the current main. Issue text is AI-drafted.
Configuration is read uncached on hot paths — two call sites, one root cause (add caching to config.get_value).
code_puppy/config.py → get_value / _load_config (callers: agents/event_stream_handler.py::_suppress_thinking_stream, _suppress_tool_progress)
What. Merged from both finder units, which reported this independently. get_value() is three lines with no memoization: config = _load_config() → config_file.load_config(CONFIG_FILE) → _read_unlocked() → atomic_io.read_bounded_bytes() (os.getsize + open + read) followed by bytes.decode and configparser.read_string() over the whole file. event_stream_handler calls this machinery inside the async for event in events: loop: every thinking PartDeltaEvent runs _suppress_thinking_stream() — level = get_output_level() then return level == "low" or get_suppress_thinking_messages(), i.e. two full parses — and every ToolCallPartDelta runs _suppress_tool_progress() for one more. The handler is what pydantic-ai awaits as event_stream_handler=stream_handler in agents/_runtime.py, so these are synchronous filesystem syscalls executed on the main event loop between streamed tokens.
Impact. A reasoning-heavy turn emitting 2,000 thinking deltas burns ~0.66 s of blocking disk read + INI parse inside the event loop, per turn. Because it blocks the loop rather than merely consuming CPU, it delays every cooperatively-scheduled coroutine — the smooth-stream drain tasks whose entire purpose is a jitter-free tick, and the Ctrl+T pause gate. The symptom is stuttering token output rather than a clean slowdown.
Fix. Memoize the parsed ConfigParser inside _load_config() keyed on (st_mtime_ns, st_size) from a single os.stat, invalidated by config_file.mutate_config. This preserves cross-process semantics (another process's write bumps mtime) at ~1.3 µs instead of 110–370 µs. Separately, hoist the two suppression decisions into locals computed once per event_stream_handler invocation — the handler already does exactly this for is_high_mode = get_output_level() == "high", so the pattern is established and simply not applied to its siblings.
code_puppy/messaging/rich_renderer.py → RichConsoleRenderer._do_render_uncoordinated → RichConsoleRenderer._should_collapse
What. Merged from both units. command_runner.run_shell_command_streaming._sink calls emit_shell_line(line, stream=stream) for every line read from the child's stdout/stderr; each becomes a ShellLineMessage on the bus. Every dequeued message passes through _do_render_uncoordinated, whose first gate is if self._should_collapse(message), and _should_collapse opens with if get_output_level() != "low": return False — one full config read and parse. ShellLineMessage is not in _NEVER_COLLAPSE and is not a TextMessage, so there is no short-circuit. TextMessage and AgentReasoningMessage hit the uncached getters two to three times per message on the same path.
Impact. Measured 175.1 µs per ShellLineMessage through the real renderer, i.e. a hard ceiling of ~5,700 lines/s, the majority of it re-parsing an unchanged file. A pytest/cargo run emitting 100k lines costs ~17.5 s of pure render-thread CPU. The consequence is worse than slowness: MessageBus.emit catches queue.Full on its 1000-slot queue.Queue and does self._outgoing.get_nowait() to drop the oldest message, so once the renderer falls behind the user silently loses transcript lines. The renderer runs on a daemon thread (threading.Thread(target=self._consume_loop_sync, daemon=True)), so it does not block the event loop directly but holds the GIL against the agent loop for that entire time.
Fix. The same mtime-keyed cache in _load_config() removes the dominant term. Independently, snapshot the output level once per renderer consume-loop batch (or cache it on the renderer instance, invalidated on the /set output_level path) rather than resolving it per message and multiple times inside a single dispatch.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in code_puppy/config.py at get_value and _load_config, then trace the callers in agents/event_stream_handler.py and messaging/rich_renderer.py. Confirm the cache is keyed by file metadata and invalidated by config_file.mutate_config, and that suppression and output-level decisions are not repeatedly resolved within event handling or rendering. Done means unchanged configuration avoids repeated parsing while updates remain visible and streamed output no longer pays the repeated lookup cost.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- performance
- Issue type
- Refactor
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 58/100