- Use
rufffor both linting and formatting (replaces black). Runruff checkandruff format. - Use
mypywith strict mode (mypy simlin). - Target Python 3.11+.
Use modern type syntax (
list[str],dict[str, int],X | None) andfrom __future__ import annotationsin all source files.
- All wrapper classes (
Project,Model,Sim) have a per-instance lock (self._lock) that protects_ptrand cached state.ProjectandModelusethreading.Lock;Simusesthreading.RLocksoSim.get_run()can hold the lock across the whole Run snapshot (which re-enters the individually locked accessors on the same thread) -- without that outer hold, a concurrentrun_to/reset/closecould land between the snapshot's reads and tear it. - Module-level
_finalizer_refs(aWeakValueDictionary) is protected by_refs_lockin_ffi.py. - When adding new methods to wrapper classes, always acquire
self._lockbefore touching_ptror mutable state. Project._file_lock(anRLock) guards a project's file-backing state (path, format, sync state, listeners, registered models) and serialises whole mutate-then-write sequences (edit()commit, autosave,reload(), the poll thread's in-place reload) so they cannot interleave. It is re-entrant because_apply_patch_jsoncommits through_commit_change_lockedwhile still holding it.- Lock ordering:
Project._file_lock->Project._lock->Model._lock.Modelmethods must releaseself._lockbefore callingProjectmethods (which acquire the project's locks) to prevent deadlocks; nothing may take_file_lockwhile holding_lock.Project._invalidate_model_cachestakes eachModel._lockbriefly under_file_lock, which is why aModelmust never call back into its project while holding its own lock. Change callbacks (Project.on_change) always run with no project lock held, and stopping the poll thread (FileWatcher.stop(), which joins) is done after releasing_file_lockbecause the thread may be blocked on it. Use double-checked locking for caches: check cache with lock, compute without lock, write cache with lock. ModelWidget(simlin/widget.py) owns one lock,_state_lock. ipywidgets/traitlets state is not thread-safe, and the kernel does not promise one thread: ipykernel 7 (JupyterLab 4.4+) runs comm handlers on a subshell thread while a cell executes, so a browser snapshot is applied, written, and pushed there, whileProject.on_changenotifications -- subscribed withdispatch=shell.kernel.io_loop.add_callbackunder ipykernel, direct outside one (scripts, tests) -- are delivered on the IO loop's thread._state_lock(anRLock) serialises the widget's two pieces of cross-thread state: the trait push (_push, onehold_sync()per(project_json, revision)pair, so two pushes can never send a torn pair) and the set of own pending revisions. It sits BELOW every Project lock in the order (taken with none held); it is necessarily held while the pair's own state message goes out (exitinghold_sync()is what sends it) and is never held around a custom-messagesend. A widget reads the project's(contents, revision)pair throughProject._snapshot(), which takes_file_lockaround both reads, and it never holds a project lock while sending on the comm.- This locking is critical for free-threaded Python (PEP 703 / Python 3.13t+ / 3.14t) where the GIL does not serialize access.
- Use
pytestwithhypothesisfor property-based testing. - Thread-safety tests live in
tests/test_thread_safety.py. - Run from
src/pysimlin:uv run pytest tests/ -x