Copilot Summary
This PR contains a proposal only — no behavior change. It adds an OpenSpec change under
openspec/changes/sandbox-run-concurrency/ (proposal.md, design.md, tasks.md, and a delta spec) recording
a performance finding in partcad/ together with a design, a measurement recipe, and the regression coverage
the fix needs. It is one of three sibling proposals from the same read-through; see "Relationship to the other
two" below.
The implementation is deliberately not included: the finding should be measured on real hardware before code is
written, and tasks.md starts with exactly that. Work through tasks.md in order — it is the handoff.
The finding
Every piece of CAD work PartCAD does happens in a sandboxed Python subprocess, and today no two of those
subprocesses ever run at the same time, on any machine, regardless of core count.
PythonRuntime.async_lock() (partcad/src/partcad/runtime_python.py:265) acquires two mutual-exclusion
primitives before yielding:
async def async_lock(self, session=None):
async with self.get_async_lock(): # asyncio.Lock, per (thread, loop, runtime)
with self.lock: # threading.RLock, ONE per runtime instance
venv = session["hash"] if session is not None else None
with VenvLock(self, venv):
yield
run_async_onced() (runtime_python.py:510) wraps that context manager around the entire subprocess
lifetime — provisioning, asyncio.create_subprocess_exec (line 561), await p.communicate() (line 571), and
the output handling — returning only at line 618.
There is effectively one PythonRuntime instance per process: Context.get_python_runtime()
(context.py:1028) memoizes by python_runtime + "-" + version, and virtually every call site asks for
"3.11" / sandbox_versions.DEFAULT_PYTHON_VERSION. One instance ⇒ one self.lock ⇒ one threading.RLock
gating every sandbox execution in the process.
Two consequences, both load-bearing
1. The thread pool is inert. ThreadPoolManager sizes a pool at cpu_count - 1 (sync_threads.py:44) and
Part.get_shape() dispatches each part's instantiation onto it (part.py:34). Every one of those threads then
blocks on the same RLock while another thread's subprocess runs. Worse: a blocking lock acquired inside a
coroutine stalls the whole OS thread, and therefore that thread's entire event loop — so pending aiofiles
cache I/O and any other async work scheduled there stalls too. The slowdown is not confined to sandbox runs.
2. Gathered renders are serial. Project.render_async() (project.py:1179) builds a coroutine for every
shape ×ばつ format combination and asyncio.gathers them on one loop (project.py:1220). They all share that
thread's single asyncio.Lock from get_async_lock(), so gather executes them strictly one at a time.
The lock is not gratuitous
runtime_python.py:511-523 documents a real hazard: installing build123d pulls cadquery-ocp-novtk, which
overwrites the very same OCP native module cadquery-ocp installs. An install slipping in between another
part's CADQUERY_OCP re-assertion and its actual run leaves that run importing a half-installed OCP and dying
with an unrelated-looking ImportError. That hazard must survive this change.
But the mutual exclusion it motivates is (venv, install-vs-run), while what is implemented is (runtime, any
run) — and sessionless runs, which install nothing at that point, are serialized along with everything else.
What is not the problem
- Not
FileLock contention: that is cross-process, and this reproduces in a single process.
- Not thread-pool sizing: the pool is sized correctly (
sync_threads.py:44-70), it is simply blocked.
- Not
Shape.get_wrapped()'s own lock (shape.py:209), though it has the same blocking-lock-across-await
shape and is worth fixing in passing (task 5.3). Its scope is one shape, so it does not serialize unrelated
work.
One claim that is unverified
VenvLock builds its FileLock with thread_local=False (runtime_python.py:146). Reading filelock's
semantics, that keeps the reentrancy counter shared across threads rather than thread-local, which would mean a
second thread's acquire() on an already-held lock object increments the counter and returns immediately —
i.e. VenvLock provides no intra-process mutual exclusion today, and all of it comes from the RLock.
That reading was not verified against the pinned filelock version. Task 1.4 settles it before anything is
designed around it. It is called out here so it is not mistaken for an established fact.
Proposed direction (details in design.md)
- D1 — a readers/writer gate keyed on the environment being mutated. Writers:
ensure* when the install
guard is absent, and v-env creation. Readers: runs that install nothing. A run holds a read on the sandbox
key and, when a session is in play, on the v-env key — in that order, so no cycle is possible. Keep
runtime_python_conda.py's global conda lock outermost.
- D2a (recommended) — keep
threading primitives for cross-thread coordination but acquire them off-loop
(await asyncio.to_thread(...)), so waiting parks the coroutine instead of freezing the thread. D2b (one
owner loop, asyncio primitives throughout) is cleaner long-term but a much larger blast radius.
- D3 — an explicit process-wide concurrency cap from
user_config.threads_max. Removing the lock uncovers
unbounded fan-out; each sandbox process is CPU- and memory-heavy, so this is resource correctness, not polish.
- D4 — leave the guard/reassert bookkeeping (
invalidate_dependent_guards(), needs_reassert(),
FORCE_REINSTALL_FLAGS) alone. A diff touching both is very hard to review.
Three open questions are recorded at the end of design.md for whoever implements this.
How to measure
No benchmark exists in the repo, so task 2 builds the baseline before any code is written. design.md
carries a self-contained recipe needing no telemetry backend: monkeypatch PythonRuntime.run_async_onced to
record (start, end) spans, then report wall clock, total sandbox busy time, run count, and peak overlap.
- Expected reading today:
peak_overlap == 1 and busy ≈ wall. That is the signature of full
serialization, and it is the "before" number to paste into this PR.
- Acceptance:
peak_overlap reaches min(runs, cap), and wall clock for N independent parts drops toward
busy / peak_overlap plus overhead. State the machine's core count — the result is meaningless without
it.
Workload: packages under examples/ (produce_part_step, produce_part_cadquery_primitive,
produce_part_build123d_primitive, produce_assembly_assy). A mixed CadQuery/build123d package is both the
interesting performance case and the race case.
Risks the implementer must handle
| Risk |
Mitigation |
| Reintroducing the OCP-clobbering race — it manifests as a native crash with no traceback and no stderr |
Task 4 is a dedicated regression test; assert on that exact signature, which runtime_python.py:601-616 already diagnoses |
Deadlock from mixed ordering with runtime_python_conda.py's global conda lock |
D1's fixed acquisition order; task 3.5 audits every acquisition site |
| Memory exhaustion once many OCP interpreters run at once |
D3's semaphore; task 6.4 records peak RSS |
| A race that reproduces one time in ten lands green |
Task 4.3 runs the regression ≥20 times, under pytest -n 4 |
Relationship to the other two proposals
Three independent findings, three independent PRs, no ordering dependency between them:
- This one lets sandbox work spread across cores.
sandbox-process-reuse reduces how much sandbox work there is (7 interpreter starts for one transformed
part rendered to 4 formats). It is complementary — this PR is a prerequisite for its benefit being visible on
a multi-core machine, but neither subsumes the other.
shape-cache-efficiency lowers the incremental-rebuild floor (a cache hit currently reads and MD5s the
whole source model).
Validation status
Only markdown under openspec/changes/ is added — no Python is touched, so pytest/behave outcomes are
unaffected. The pre-commit hooks did not run locally: this session's container has no working Docker daemon,
so the dev container documented in the root AGENTS.md could not be started, and host-level pre-commit is
deliberately not used (host tool versions are not the pinned ones). CI will run the gates. Everything in
tasks.md is written to be run inside the dev container.
Generated by Claude Code
Copilot Summary
This PR contains a proposal only — no behavior change. It adds an OpenSpec change under
openspec/changes/sandbox-run-concurrency/(proposal.md,design.md,tasks.md, and a delta spec) recordinga performance finding in
partcad/together with a design, a measurement recipe, and the regression coveragethe fix needs. It is one of three sibling proposals from the same read-through; see "Relationship to the other
two" below.
The implementation is deliberately not included: the finding should be measured on real hardware before code is
written, and
tasks.mdstarts with exactly that. Work throughtasks.mdin order — it is the handoff.The finding
Every piece of CAD work PartCAD does happens in a sandboxed Python subprocess, and today no two of those
subprocesses ever run at the same time, on any machine, regardless of core count.
PythonRuntime.async_lock()(partcad/src/partcad/runtime_python.py:265) acquires two mutual-exclusionprimitives before yielding:
run_async_onced()(runtime_python.py:510) wraps that context manager around the entire subprocesslifetime — provisioning,
asyncio.create_subprocess_exec(line 561),await p.communicate()(line 571), andthe output handling — returning only at line 618.
There is effectively one
PythonRuntimeinstance per process:Context.get_python_runtime()(
context.py:1028) memoizes bypython_runtime + "-" + version, and virtually every call site asks for"3.11"/sandbox_versions.DEFAULT_PYTHON_VERSION. One instance ⇒ oneself.lock⇒ onethreading.RLockgating every sandbox execution in the process.
Two consequences, both load-bearing
1. The thread pool is inert.
ThreadPoolManagersizes a pool atcpu_count - 1(sync_threads.py:44) andPart.get_shape()dispatches each part's instantiation onto it (part.py:34). Every one of those threads thenblocks on the same
RLockwhile another thread's subprocess runs. Worse: a blocking lock acquired inside acoroutine stalls the whole OS thread, and therefore that thread's entire event loop — so pending
aiofilescache I/O and any other async work scheduled there stalls too. The slowdown is not confined to sandbox runs.
2. Gathered renders are serial.
Project.render_async()(project.py:1179) builds a coroutine for everyshape ×ばつ format combination and
asyncio.gathers them on one loop (project.py:1220). They all share thatthread's single
asyncio.Lockfromget_async_lock(), sogatherexecutes them strictly one at a time.The lock is not gratuitous
runtime_python.py:511-523documents a real hazard: installingbuild123dpullscadquery-ocp-novtk, whichoverwrites the very same OCP native module
cadquery-ocpinstalls. An install slipping in between anotherpart's
CADQUERY_OCPre-assertion and its actual run leaves that run importing a half-installed OCP and dyingwith an unrelated-looking
ImportError. That hazard must survive this change.But the mutual exclusion it motivates is (venv, install-vs-run), while what is implemented is (runtime, any
run) — and sessionless runs, which install nothing at that point, are serialized along with everything else.
What is not the problem
FileLockcontention: that is cross-process, and this reproduces in a single process.sync_threads.py:44-70), it is simply blocked.Shape.get_wrapped()'s own lock (shape.py:209), though it has the same blocking-lock-across-awaitshape and is worth fixing in passing (task 5.3). Its scope is one shape, so it does not serialize unrelated
work.
One claim that is unverified
VenvLockbuilds itsFileLockwiththread_local=False(runtime_python.py:146). Readingfilelock'ssemantics, that keeps the reentrancy counter shared across threads rather than thread-local, which would mean a
second thread's
acquire()on an already-held lock object increments the counter and returns immediately —i.e.
VenvLockprovides no intra-process mutual exclusion today, and all of it comes from theRLock.That reading was not verified against the pinned
filelockversion. Task 1.4 settles it before anything isdesigned around it. It is called out here so it is not mistaken for an established fact.
Proposed direction (details in
design.md)ensure*when the installguard is absent, and v-env creation. Readers: runs that install nothing. A run holds a read on the sandbox
key and, when a session is in play, on the v-env key — in that order, so no cycle is possible. Keep
runtime_python_conda.py's global conda lock outermost.threadingprimitives for cross-thread coordination but acquire them off-loop(
await asyncio.to_thread(...)), so waiting parks the coroutine instead of freezing the thread. D2b (oneowner loop,
asyncioprimitives throughout) is cleaner long-term but a much larger blast radius.user_config.threads_max. Removing the lock uncoversunbounded fan-out; each sandbox process is CPU- and memory-heavy, so this is resource correctness, not polish.
invalidate_dependent_guards(),needs_reassert(),FORCE_REINSTALL_FLAGS) alone. A diff touching both is very hard to review.Three open questions are recorded at the end of
design.mdfor whoever implements this.How to measure
No benchmark exists in the repo, so task 2 builds the baseline before any code is written.
design.mdcarries a self-contained recipe needing no telemetry backend: monkeypatch
PythonRuntime.run_async_oncedtorecord
(start, end)spans, then report wall clock, total sandbox busy time, run count, and peak overlap.peak_overlap == 1andbusy ≈ wall. That is the signature of fullserialization, and it is the "before" number to paste into this PR.
peak_overlapreachesmin(runs, cap), and wall clock for N independent parts drops towardbusy / peak_overlapplus overhead. State the machine's core count — the result is meaningless withoutit.
Workload: packages under
examples/(produce_part_step,produce_part_cadquery_primitive,produce_part_build123d_primitive,produce_assembly_assy). A mixed CadQuery/build123d package is both theinteresting performance case and the race case.
Risks the implementer must handle
runtime_python.py:601-616already diagnosesruntime_python_conda.py's global conda lockpytest -n 4Relationship to the other two proposals
Three independent findings, three independent PRs, no ordering dependency between them:
sandbox-process-reusereduces how much sandbox work there is (7 interpreter starts for one transformedpart rendered to 4 formats). It is complementary — this PR is a prerequisite for its benefit being visible on
a multi-core machine, but neither subsumes the other.
shape-cache-efficiencylowers the incremental-rebuild floor (a cache hit currently reads and MD5s thewhole source model).
Validation status
Only markdown under
openspec/changes/is added — no Python is touched, sopytest/behaveoutcomes areunaffected. The pre-commit hooks did not run locally: this session's container has no working Docker daemon,
so the dev container documented in the root
AGENTS.mdcould not be started, and host-levelpre-commitisdeliberately not used (host tool versions are not the pinned ones). CI will run the gates. Everything in
tasks.mdis written to be run inside the dev container.Generated by Claude Code