← Back to Blog

A crash hiding in plain sight: when `watch` shadows `watchfiles.watch`

·5 min read

The most embarrassing bugs are the ones that work in every code path you tested and fail instantly in the one you did not. PR #135 fixed exactly that kind of bug in pyrpc watch: a name collision so ordinary, a function called watch() shadowingfrom watchfiles import watch, that it survived several releases.

The collision

cli.py imports watchfiles’s watch at module scope. Later, the watch command grew its own local helper also named watch. Python resolves names at runtime, so inside that command the local binding won, and calling what looked like the watcher invoked the helper instead, which raised a TypeError the moment the command ran:

# module scope
from watchfiles import watch

def watch_command(...):
 ...
 for changes in watch( # <- resolves to the local helper, not watchfiles
 ...):

The failure mode is worth naming. Nothing was wrong with the import, the arguments, or the watcher loop, the symbol table was wrong. Static analyzers flag this only if configured to care about shadowed imports, and the tests mocked watchfiles.watch at the module boundary, which conveniently bypassed the broken resolution entirely.

The fix is four lines; the guard is ten

Renaming the local helper restores the intended resolution. But the regression test matters more than the rename. It invokes the watch command end-to-end and asserts the real watcher receives a sane call, so any future shadowing fails loudly in CI instead of silently at a user’s terminal:

def strict_watch(*args, **kwargs):
 assert "stop_event" not in kwargs, "watch() got stop_event - not portable"
 return iter([])

That assertion does double duty: it pins the #135 fix (the symbol must resolve to watchfiles) and the #132 contract (stop_event is unsupported by every watchfiles release, so pyRPC signals shutdown via stop events and yield_on_timeout instead).

The general lesson

Three habits would have caught this earlier, and all three are cheap:

  • Import modules, not symbols, when the symbol name is generic: import watchfiles then watchfiles.watch(...). Shadowing becomes impossible.
  • Never mock the thing you are testing through. Mocking at the module boundary made every test pass while the production call path was broken. Mock one level deeper or assert on the resolved callable.
  • Test commands by invoking them, not by unit-testing their internals. The bug lived in the wiring between the two.

It shipped in v0.13.0 as a one-commit fix. Most of engineering is avoiding clever architecture; some of it is remembering that watch is a very popular name.