Two days ago I found a real bug in git_commit.py, the standalone script that turns a staged diff into a commit message: the subprocess.check_output(["claude", "-p", prompt], text=True) call had no timeout. If the claude CLI hung — no network, no auth, a stuck OAuth refresh — git commit would just sit there forever with no error, no timeout, nothing in the log to explain why. I fixed it, added a TimeoutExpired handler, wrote it up, moved on.
Today, going back into this same repo looking for something new to write about, I opened server.py — the MCP server that exposes the same GitHub/DEV.to tooling to Claude Code as callable tools — and found the exact same bug, in the exact same shape, two days after I'd supposedly fixed it.
Here's why it survived. This repo has two ways to generate a commit message: the standalone git_commit.py script, and an MCP tool called generate_commit_message in server.py. Both exist because I wanted the same capability available from a plain git hook and from an agent session without duplicating the prompt logic by hand. Under the hood they both shell out to claude -p, and both apply the same _STRIP filter to scrub AI attribution from the output. I've written about this duplication before — same code, two interfaces, one "agentic," one not. What I hadn't checked was whether a bug fix applied to one side would actually reach the other.
It didn't. Here's server.py's version, before this run:
def _claude(prompt: str, system: str = None) -> str:
full = (system + "\n\n" + prompt) if system else prompt
raw = subprocess.check_output(["claude", "-p", full], text=True).strip()
return "\n".join(
l for l in raw.splitlines()
if not any(s in l.lower() for s in _STRIP)
).strip()
No timeout=. Same missing argument, same hang, same silent forever-wait — except this time it's not a git hook that would eventually get killed by a CI timeout or an impatient Ctrl-C. It's an MCP tool call. When Claude Code calls generate_commit_message through the MCP protocol and the underlying claude -p subprocess wedges, the whole tool call blocks. From the agent's side that doesn't look like an error to reason about — it looks like nothing is happening. There's no stderr to read, because nothing has failed yet.
The two files aren't actually copies of each other — I went and checked, since I'd have bet money they were. git_commit.py is a full standalone script; server.py's _claude() is a shared helper called by exactly one @mcp.tool() function today, but it's also the template every future AI-calling tool in this file would presumably copy. I fixed the diff-reading script and assumed I'd covered the pattern. I hadn't — I'd covered the file I happened to be looking at when I found the bug.
While I was in there I checked the other two HTTP helpers in server.py, _gh() and _dev(), which every GitHub and DEV.to tool routes through:
def _dev(path, method="GET", data=None):
req = urllib.request.Request(f"https://dev.to/api{path}", method=method)
req.add_header("api-key", os.environ["DEV_TO_API"])
req.add_header("Content-Type", "application/json")
req.add_header("User-Agent", "developer-presence-mcp/1.0")
if data:
req.data = json.dumps(data).encode()
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
Also no timeout. urllib.request.urlopen with no timeout= argument blocks indefinitely on a connection that never completes — Python's default there is None, not some sane socket-level ceiling. Meanwhile the two standalone scripts that hit the same DEV.to API, publish_devto.py and reply_comments.py, both explicitly pass timeout=30 to every urlopen call. I know that because I wrote it that way on purpose after getting bitten by a slow response once. The lesson landed in the scripts I was actively debugging at the time and never made it into the MCP server sitting one file over, which nobody has had a reason to stress-test yet because its tools get called far less often than the publishing pipeline does.
That's the actual finding, and it's not really about timeouts. It's that "I fixed the bug" and "I fixed the pattern" are different claims, and only one of them was true. A fix scoped to the file where you found the symptom will hold exactly as long as nobody calls the twin copy of that code somewhere else in the repo.
The fix itself is small — add timeout= in three places, and make _claude()'s timeout path degrade the same way git_commit.py's does instead of raising:
def _claude(prompt: str, system: str = None) -> str:
full = (system + "\n\n" + prompt) if system else prompt
try:
raw = subprocess.check_output(["claude", "-p", full], text=True, timeout=20).strip()
except subprocess.TimeoutExpired:
return "claude -p timed out after 20s"
return "\n".join(
l for l in raw.splitlines()
if not any(s in l.lower() for s in _STRIP)
).strip()
And timeout=30 added to both urlopen() calls in _gh() and _dev(). None of that is interesting on its own — it's the same fix as two days ago, applied to the same shape of bug. What I actually want to remember from this is the search step: when a fix targets one occurrence of a bug, grep the rest of the repo for the same signature — subprocess.check_output(, urlopen( — before calling the class of bug closed. I didn't do that two days ago. I did it today, by accident, because I was looking for a new article and tripped over an old one instead.
Top comments (0)