✈ Pre-Flight checks
🐞 Describe the bug
In a long Revit 2026 session, every pyRevit command freezes the Revit UI for ~8–13 seconds — including a no-op IronPython script. The freeze is not the script: instrumentation shows the script body completes in ~1.3 s (or milliseconds for a no-op), while the Revit journal's API External Command Time reports 8.5–13.2 s for the same click. A built-in Revit command (Thin Lines) executed in 6 ms in the same session, so the tax is specific to the pyRevit command pipeline.
Measuring the .NET runtime across a single button click (via GC.GetTotalPauseDuration() / GC.CollectionCount() from another add-in in the same process):
before: gen0=12030 gen1=2249 gen2=158 pause_ms=469945 heap_mb=9217
after: gen0=12062 gen1=2270 gen2=160 pause_ms=479607 heap_mb=9263
One click = +9.7 s of GC pause time and +2 gen2 (full, blocking) collections on a ~9.2 GB managed heap. The freeze is the garbage collector, and each pyRevit command is what triggers it. Session-wide symptoms match: even ribbon tooltips take 0.3–3.2 s (Jrn.Tooltip … ElapsedTime) between commands.
Why every command triggers full GCs: every click executes on a brand-new IronPython engine. A script-level marker (sys._counter, which survives inside a cached engine) reset to 1 on every click, and the journal shows 300–450 MB of allocation churn per command. A fresh engine per click (assembly-bound scopes, recompiled pyrevit package imports, etc.) generates enough garbage to force gen2 collections, and on a multi-gigabyte heap each one blocks the UI thread for several seconds. The effect compounds as the session heap grows, which is why the same buttons feel fine in a fresh session and take ~10 s hours later.
Suspected mechanism (from reading master): this session had used pyRevit → Reload. After a reload on .NET 8 / Revit 2026, the runtime types live in a new AssemblyLoadContext, so in ScriptEngineManager.GetCachedEngine<T>() the cast of the previously cached engine object throws InvalidCastException and returns null — the cache misses forever after a reload:
GetEngine<T> → cache miss → new engine created every click;
SetCachedEngine<T> → its inner GetCachedEngine also cast-misses → the stale engine is replaced in the dict without Shutdown() (CleanupBuiltins/CleanupStreams never run on it).
The engines dictionary itself is alive and correctly keyed ({SessionUUID}:IronPython:{Extension} — verified via AppDomain.CurrentDomain.GetData("PYREVITCachedEngines"), 3 entries), it just can never be hit across an ALC boundary.
⌨ Error/Debug Message
No error is raised. Journal excerpts for consecutive clicks of the same extension button
(a launcher whose script body completes in ~1.3 s):
' 10.238982 1:<<<API External Command Time
' 10.110983 1:<<<API External Command Time
' 10.160997 1:<<<API External Command Time
Same button with its script replaced by a no-op (write one line to a temp file):
' 8.487061 1:<<<API External Command Time
Built-in Revit command (Thin Lines), same session, same minute: 6 ms.
♻️ To Reproduce
- Revit 2026, pyRevit 6.4.0, any IronPython extension button (a no-op
script.py is enough).
- Work a long session so the process' managed heap grows to several GB; use pyRevit → Reload at least once.
- Click any pyRevit button and watch the UI freeze for many seconds; check
API External Command Time in the journal, or GC.GetTotalPauseDuration() before/after the click.
- In-engine marker (
import sys; sys._n = getattr(sys, "_n", 0) + 1, logged to a file) shows the engine is fresh on every click.
⏲️ Expected behavior
- The cached engine is reused across clicks of the same extension (as designed), including after a Reload — or the cache is keyed/stored in a way that survives the new AssemblyLoadContext.
- A stale engine that is dropped from the cache gets
Shutdown() before being replaced.
- A pyRevit command on a warm engine should cost milliseconds, not two full GC cycles.
🖥️ Hardware and Software Setup
pyRevit version: 6.4.0.26100+0515 (deployed from image), IronPython engine 342, rocket mode on, telemetry off
pyRevit environment: single clone "master", one third-party extension + pyRevitCore/pyRevitTools
Revit: 2026.4 (26.4.0.32, build 20251103_1515(x64)), .NET 8
OS: Windows 11 Pro 10.0.26200
Hardware: 32 GB RAM, local SSD install (no network paths, no SentinelOne)
Additional context
✈ Pre-Flight checks
master(ScriptEngineManager.cs,ScriptEngines.cs,IronPythonEngine.cs)🐞 Describe the bug
In a long Revit 2026 session, every pyRevit command freezes the Revit UI for ~8–13 seconds — including a no-op IronPython script. The freeze is not the script: instrumentation shows the script body completes in ~1.3 s (or milliseconds for a no-op), while the Revit journal's
API External Command Timereports 8.5–13.2 s for the same click. A built-in Revit command (Thin Lines) executed in 6 ms in the same session, so the tax is specific to the pyRevit command pipeline.Measuring the .NET runtime across a single button click (via
GC.GetTotalPauseDuration()/GC.CollectionCount()from another add-in in the same process):One click = +9.7 s of GC pause time and +2 gen2 (full, blocking) collections on a ~9.2 GB managed heap. The freeze is the garbage collector, and each pyRevit command is what triggers it. Session-wide symptoms match: even ribbon tooltips take 0.3–3.2 s (
Jrn.Tooltip … ElapsedTime) between commands.Why every command triggers full GCs: every click executes on a brand-new IronPython engine. A script-level marker (
sys._counter, which survives inside a cached engine) reset to 1 on every click, and the journal shows 300–450 MB of allocation churn per command. A fresh engine per click (assembly-bound scopes, recompiledpyrevitpackage imports, etc.) generates enough garbage to force gen2 collections, and on a multi-gigabyte heap each one blocks the UI thread for several seconds. The effect compounds as the session heap grows, which is why the same buttons feel fine in a fresh session and take ~10 s hours later.Suspected mechanism (from reading
master): this session had used pyRevit → Reload. After a reload on .NET 8 / Revit 2026, the runtime types live in a new AssemblyLoadContext, so inScriptEngineManager.GetCachedEngine<T>()the cast of the previously cached engine object throwsInvalidCastExceptionand returnsnull— the cache misses forever after a reload:GetEngine<T>→ cache miss → new engine created every click;SetCachedEngine<T>→ its innerGetCachedEnginealso cast-misses → the stale engine is replaced in the dict withoutShutdown()(CleanupBuiltins/CleanupStreamsnever run on it).The engines dictionary itself is alive and correctly keyed (
{SessionUUID}:IronPython:{Extension}— verified viaAppDomain.CurrentDomain.GetData("PYREVITCachedEngines"), 3 entries), it just can never be hit across an ALC boundary.⌨ Error/Debug Message
♻️ To Reproduce
script.pyis enough).API External Command Timein the journal, orGC.GetTotalPauseDuration()before/after the click.import sys; sys._n = getattr(sys, "_n", 0) + 1, logged to a file) shows the engine is fresh on every click.⏲️ Expected behavior
Shutdown()before being replaced.🖥️ Hardware and Software Setup
Additional context
telemetry_server_urlmangled), in case both stem from the same config read/write layer.