Type regeneration looks simple from the outside, a file changes, types update. Under the hood it is a small concurrency puzzle: a timer that must reset on every change, a lock that must prevent overlapping regeneration, and a thread that must survive being interrupted. And once, it was broken by a single missing import.
Why a timer at all
Every save triggers multiple filesystem events, and every event would naively trigger a regeneration. The debounce collapses them: instead of regenerating on the first event, wait 300ms and regenerate once, after the burst settles. The canonical implementation:
_DEBOUNCE_SECONDS = 0.3 def _schedule_regen(): with _timer_lock: if _timer is not None: _timer.cancel() _timer = threading.Timer(_DEBOUNCE_SECONDS, _do_regen) _timer.daemon = True _timer.start()
Each new event cancels the pending timer and starts a fresh one. If saves stop, the last timer fires 300ms later and regeneration runs against the final, complete file, never against a half-written intermediate state.
Why a lock on the timer
schedule_regen is called from two threads, the file watcher and thepyrpc.json watcher. Without a lock, two threads can interleave:cancel(), then both start(), leaving two timers armed. The lock serializes the read-modify-write on _timer, guaranteeing at most one pending timer exists at any moment.
Why regeneration needs its own lock
The debounce guarantees scheduling is serialized, but not execution. The timer callback itself can be slow, a regeneration imports the module and reads the whole registry. If a regen is in flight when another fires, two threads would write the same__pyrpc.d.ts concurrently. The regen lock makes that impossible:
def _do_regen(): if not _regen_lock.acquire(blocking=False): return # a regen is already running; skip try: _regenerate_clients(module, client_dirs) finally: _regen_lock.release()
The non-blocking acquire is a deliberate choice: if a regen is already running, the newest trigger is dropped rather than queued. That is safe because regeneration is idempotent , the running regen reads the same latest module state, so dropping the duplicate changes nothing.
The missing import time
The 0.10.1 → 0.11.0 changelog contains a one-line fix that reads like a comedy of errors:
- fix: import time module in watcher regen callback
time.time() is called inside _do_regen to stamp the regeneration log line, but the import time lived in another scope of the file. Python’s scoping rules turned a missing import into a silent NameError at runtime, caught by the surrounding exception handler and never shown. The symptom was insidious: types still regenerated, but no regen ✓ line appeared, so the tool seemed to have stopped working when it had actually just stopped talking.
The fix was moving the import into the callback scope. The lesson is broader: in Python, an import missing from a nested function is a runtime error, not a compile-time one. If a function uses a module, import it inside that function (or be disciplined about module-level imports), silent failure beats no failure only by being detectable.
The pieces together
- Watch thread, produces events, filtered to
.pyand config changes. - Timer (debounced), collapses bursts, guards against half-written files.
- Timer lock, serializes scheduling across threads.
- Regen lock, prevents concurrent writes to the same output file.
- The log line, the only outward signal the loop is alive.
Individually each piece is trivial. Together they make “save, wait, types are fresh” safe on any editor, any filesystem, and under real concurrency.
Read the full changelogfor the complete list of changes.

pyRPC