Date: 2026-09-11
Amended: 2026-09-11 — IPy 3.4 win32/cli shim; port 5680 (later 4567); pyRevit yields
when a debugger client is attached.
Amended: 2026-09-12 — VS Code/Cursor client is debugpy attach+connect;
IpyDebugger.py handshake + expand-getattr quieting. In-process
debugger remains PyDev.Debugger 2.8.0 on port 4567 (was 5680).
Amended: 2026-09-13 — two IronPython stacks, one debugger.
pyRevit loaded → ScriptExecutor + static IronPythonDebugger on the loader
engine (full_frame: false, reuse). No pyRevit → embedded 3.4.2 via
IronPythonInitializer session engine. Do not CreateEngine on the pyRevit path.
Amended: 2026-09-15 — static IronPythonDebugger / PythonDebugger; singleton
DebugEndpoints (Port, Attached, IsListening); no IDebuggerBridge.
Amended: 2026-09-14 — live Revit 2024 (.NET 4.8): import pydevd succeeds;
_enable_attach → import site → os.path.abspath on CLR __file__
(assembly display name). net48 Path.GetFullPath rejects it. Wrap
abspath in IpyDebugger.py before _enable_attach. API-thread
sys.settrace inside ScriptSource.Execute double-pops IronPython
FunctionStack (PopFrame RemoveAt, Parameter 'index'). Prepare
the callback in Python, then PythonContext.SetTrace from C#. Do not
edit AppData pydevd.
Accepted.
Companion to 0025 (CPython
debugpy listen-on-port is a separate DAP path) and
0026 (pyRevit-first when
loaded). Living maps stay in
code-execution.md after accept.
CPython already listens with PythonDebugger / debugpy on preferred port
5678. That stack is pythonnet / CPython. IronPython (*_ipy_script.py) cannot
import debugpy, cannot pip install a CPython wheel into the DLR engine, and
today creates the embedded engine with Python.CreateEngine() without
Frames unless IronPythonDebugger owns the session. pyRevit Labs (when
loaded) uses one EngineConfigsJson with full_frame: false and
clean: false so the pydevd listener survives across Runs.
sys._getframe is a hard requirement of PyDev.Debugger. pydevd 2.8.0 source
treats IronPython as a first-class target (IS_IRONPYTHON = sys.platform == 'cli'), includes _current_frames / functools.partial + sys.settrace
workarounds, and disables Cython on non-CPython. That is why 2.8.0 is the
debugger, not “hope Python 2 code runs.”
Embedded IronPython 3.4.2 reports sys.platform as win32 (not cli).
pydevd 2.8.0 sets IS_IRONPYTHON from platform == 'cli' and IS_WINDOWS
from platform == 'win32' — they cannot both be true from one sys.platform.
Listen imports _pydevd_bundle.pydevd_constants under cli so
IS_IRONPYTHON is true, then forces IS_WINDOWS = True, restores
sys.platform, then import pydevd. Leaving IS_WINDOWS false makes
breakpoint matching case-sensitive (Samples vs samples, C: vs c:)
and the hit never fires. Do not fork pydevd.
Live Revit 2024 (.NET 4.8): import pydevd is fine. Failure is
pydevd._enable_attach → FilesFiltering → import site →
os.path.abspath on CLR __file__ values that are assembly display names.
net48 Path.GetFullPath rejects them (Specified path is invalid.); net8+
does not. IpyDebugger.py wraps abspath before _enable_attach. CPython
never throws from abspath.
pydevd 2.8.0 DAP attach exists as _enable_attach / _wait_for_attach /
_is_attached, but the default wire protocol is QUOTED_LINE_PROTOCOL,
not DAP. The 3.x convenience settrace(..., protocol="dap") is not in 2.8.0.
HTTP_JSON_PROTOCOL is the Content-Length JSON framing DAP uses;
JSON_PROTOCOL is raw JSON without headers.
VS Code Python Debugger (debugpy) talks DAP directly to the in-process
2.8 listener. pydevd 2.8 does not emit InitializedEvent after attach;
IpyDebugger.py synthesizes it when adapterID == "debugpy". That
handshake is proven live. Do not import the debugpy package into
IronPython (CPython-only). Do not require the PyDev VS Code extension.
Adopt fabioz/PyDev.Debugger tag pydev_debugger_2_8_0 as the in-process
debugger for ExecutionMode.IronPython. The VS Code/Cursor attach config is
"type": "debugpy" connect localhost:4567 — same client as CPython, not
the same server or socket.
Refuse:
- Importing the
debugpypackage into IronPython (CPython-only; already the CPython path). - pydevd 3.x as the first IronPython debugger (IronPython is not the supported in-process target; protocol APIs differ).
- Unifying this listener with
PythonDebuggeror sharing port 5678 (0025 alternative 2 already refused merging DAP stacks).
CPython keeps debugpy. CLR/coreclr attach still does not debug IronPython
locals.
On host add-in startup, ensure the zip is present under
AppUtils.GetApplicationDataPath() (%APPDATA%\RevitDevTool), same owner as
uv/pixi:
- URL:
https://github.com/fabioz/PyDev.Debugger/archive/refs/tags/pydev_debugger_2_8_0.zip - Extract root (search-path root, contains
pydevd.py):%APPDATA%\RevitDevTool\pydevd\PyDev.Debugger-pydev_debugger_2_8_0 - Version marker next to that tree (uv-style). Skip download when the marker
matches
2.8.0. Offline hosts keep the last extract.
Refuse: NuGet for pydevd, pip install pydevd on IronPython, vendoring the
tree in git, forking/patching pydevd for v1, deleting .pyd, or setting
PYDEVD_USE_CYTHON=0 (2.8.0 already skips Cython on non-CPython).
var engine = IronPython.Hosting.Python.CreateEngine(new Dictionary<string, object>
{
["Frames"] = true,
["FullFrames"] = true,
});Frames=true is required (sys._getframe). FullFrames=true stays on so the
debugger can inspect locals.
Do not set Tracing=true (or engine-wide Debug=true) at CreateEngine.
IronPython 3.4 then rewrites every method for sys.settrace before
import pydevd; importing 2.8.0 fails with
Unable to cast … FieldExpression to BlockExpression. Tracing still starts
when settrace is called — user files are compiled after that.
_enable_attach traces the calling thread. Host start runs its
synchronous prefix on the CAD UI thread, so listen must run on a
background thread — same idea as CPython debugpy.listen, which does not
pin the debugger to the API thread. Do not RunOnMainThread around listen
or settrace. If the UI thread is traced at attach time, a breakpoint
suspends Revit and VS Code never shows a hit.
Script execution still uses IHostContextExecutor because Revit API
requires it, not because pydevd does. Before compiling a user file on that
API thread, bind the pydevd callback with set_additional_thread_info in
Python then PythonContext.SetTrace from C#. Do not call
sys.settrace / py_db.enable_tracing() / pydevd.settrace() inside
ScriptSource.Execute: IronPython RunWorker pushes FunctionStack,
tracing FrameExit pops it, then PopFrame RemoveAt throws
ArgumentOutOfRangeException (Parameter 'index'). User code is compiled
after this, so existing frames do not need f_trace.
set_trace_to_threads is CPython-only. Do not skip installing the thread
trace while already on the debug engine waiting for ConfigurationDone.
Non-debug and debug IronPython share one session engine only on the embedded 3.4.2 path (AutoCAD, Revit without pyRevit). That engine is created with Frames/FullFrames and without Tracing.
When pyRevit is loaded, every *_ipy_script.py Run uses pyRevit
ScriptExecutor / PyRevitLoader — not embedded 3.4.2.
IronPythonInitializer attaches via IIronPythonBridge.TryGetHostEngine;
static IronPythonDebugger.StartListeningAsync listens on that loader
engine (no CreateEngine, no Runtime.Shutdown).
EngineConfigsJson: clean:false, persistent:false,
full_frame:false, RefreshEngine:false. Do not set
full_frame (pyRevit would set Tracing; IronPython 3.4 then
fails import pydevd). User-extension modules are dropped from
sys.modules before each Run so edited files reload. Embedded 3.4.2
is AutoCAD / Revit-without-pyRevit only. Do not reopen 0026 for routing
(loaded vs not).
Insert the extract root (the directory that contains pydevd.py,
_pydev_bundle, _pydev_imps, _pydevd_bundle) at the front of
engine.GetSearchPaths(). Do not add _pydevd_bundle or pydevd_ipython
as sibling roots.
Product attach matches CPython PythonDebugger: the host starts a listener
and does nothing else. VS Code attaches to the port on its own. Script
Run does not grow a “Debug Script / wait / attach” gesture.
CPython (PythonDebugger) |
IronPython (IronPythonDebugger) |
|---|---|
debugpy.listen(...) at interpreter init |
pydevd._enable_attach(("127.0.0.1", 4567)) at engine init |
no debugpy.wait_for_client() |
no pydevd._wait_for_attach() |
debugpy.is_client_connected() |
pydevd._is_attached() |
| listener lives with the process | same — see decision 8 |
Order when starting the listener (once per host session):
import pydevd(after thecli/IS_WINDOWSshim)PydevdCustomization.DEFAULT_PROTOCOL = HTTP_JSON_PROTOCOLpydevd._enable_attach(("127.0.0.1", port))
Refuse _wait_for_attach() / WaitForClient as the product path. That API is
debugpy.wait_for_client(): it blocks until DAP ConfigurationDone. This
product does not wait on CPython and will not wait on IronPython.
Refuse relying on 2.8.0’s default QUOTED_LINE_PROTOCOL. Refuse
settrace(..., protocol="dap") as the 2.8.0 API. DEFAULT_PROTOCOL must be
assigned before _enable_attach because PyDB() snapshots it into the
command factory.
After _enable_attach, set
py_db.multi_threads_single_notification = True. In 2.8.0
NetCommandFactoryJson.make_thread_suspend_message is always
NULL_NET_COMMAND; the DAP StoppedEvent is only emitted from
make_thread_suspend_single_notification when that flag is true. The VS
Code PyDev 0.3.0 adapter (bundled pydevd 3.4.1) still sends a per-thread
StoppedEvent when the flag is false, and does not send
multiThreadsSingleNotification (# this is now passed in the launch request). Without the flag, 2.8 suspends the host thread and waits for
continue while VS Code never shows a hit. This is host configuration, not
a pydevd fork.
Preferred port is 4567. CPython debugpy keeps 5678
(0025). The two listeners must
not share a socket. If 4567 is taken, pick an ephemeral port and surface
it. VS Code attach is "type": "debugpy" connect to 127.0.0.1:4567 (or
the surfaced fallback). Handshake (InitializedEvent, unknown DAP command
replies) lives in IpyDebugger.py, not a pydevd fork.
Breakpoints at the first lines of a script hit only if the client is already
attached — same as today’s CPython debugpy (no wait). Attach after a Run
has finished sees nothing: the script is over.
Same contract as CPython (compile(__source__, __file__, 'exec')):
Path.GetFullPath(scriptPath)File.ReadAllText(UTF-8)CreateScriptSourceFromString(code, canonicalPath, SourceCodeKind.File)__file__/__name__ == '__main__'on the scope
Refuse CreateScriptSourceFromFile as the debug source: DLR file sources can
put a URI or a path that is not the string VS Code sent. Refuse
engine.Execute(code) / <string>. pydevd matches breakpoints on
filename + line.
Shared DevTools.Execution owns static IronPythonDebugger parallel to static
PythonDebugger:
public static class IronPythonDebugger
{
public static void AddPydevdSearchPath(ScriptEngine engine);
public static Task StartListeningAsync(object engine, DebugEndpoint endpoint, …);
public static void EnsureCurrentThreadTraced(object engine);
}Attach state is probed into DebugEndpoint.Attached via PythonInstances
constants. No WaitForClient. Do not inject import pydevd / settrace into
each *_ipy_script.py. Do not put these types in RevitDevTool / AcadDevTool.
Hosts keep IIronPythonBridge for builtins/references and
TryGetHostEngine only. pyRevit SearchPaths are the command-generator list
(script / lib / bin / pyrevitlib). Do not inject pyRevit paths into the embedded
3.4 engine.
Singleton DebugEndpoints holds CPython and IronPython DebugEndpoint slots
(Port, Attached, IsListening). Execution UI reads them directly. The two
listeners stay on separate sockets.
debugpy works without wait because pythonnet is one process-lifetime
interpreter; PythonDebugger.StartListening runs in SetupRuntime, then
any later script is the same sys.settrace. A per-script engine that calls
Runtime.Shutdown() after each Run is not attachable: the socket dies before
VS Code can connect. IronPythonExecutor reuses the IronPythonInitializer
session engine instead.
Therefore the embedded debug engine is session-lifetime:
- Host start (or first embedded IPy init): create Frames engine, add PyDev
root,
StartListening, keep the engine. - User attaches VS Code to 4567 whenever — host has no extra click.
*_ipy_script.pyon the embedded path executes on that engine (CreateScriptSourceFromStringwith the on-disk path), not on a fresh engine that is then shutdown.- Do not
Runtime.Shutdown()that engine at the end of each script.
pyRevit-first Run is 0026 plus IronPythonDebugger on the same loader engine. AutoCAD and Revit-without-pyRevit keep embedded IPy 3.4.2.
_enable_attach starts a background listener thread. Do not call it on a
throwaway engine. Do not _wait_for_attach on the Revit UI thread.
- debugpy on IronPython. Rejected. CPython/pythonnet only.
- pydevd 3.x as the IronPython debugger. Rejected for v1. 2.8.0 is the
last line that documents in-process IronPython (
cli,_getframe, Cython off). Revisit only if a later pydevd restores that target. - pip / NuGet / git-vendored tree. Rejected. IronPython is not a pip target here; AppData zip matches uv/pixi bootstrap.
- Fork or patch pydevd. Rejected for v1. Upstream 2.8.0 already has the IronPython workarounds.
- One
PythonDebugger/ one listen port for both runtimes. Rejected. Different interpreter, different in-process DAP server (debugpy vs pydevd 2.8). The client type can be the same (debugpyattach to 5678 or 4567). Do not merge sockets. - User-script boilerplate (
import pydevdin every file). Rejected. Host configures the engine. - Execute debug code as
<string>. Rejected. Breakpoints will not bind. - Adopt pyRevit's DLR into a second debugger type.
Rejected. One IronPythonDebugger; DlrScriptHost already
talks to both typed 3.4 and pyRevit's DLR. pyRevit
full_frame/Tracingstill must not be set (IronPython 3.4import pydevdthrows FieldExpression/BlockExpression). Do not Runtime.Shutdown the loader engine. _wait_for_attach/WaitForClienton each debug Run. Rejected as product UX. It isdebugpy.wait_for_client(), which this product does not use. It freezes the host until VS Code attaches and is a different gesture from CPython. Keep_wait_for_attachout of the API; a spike may still call it once to prove DAP handshake, then delete it.- Per-script engine + listen-without-wait. Rejected. Socket and
settracedie inShutdownEnginebefore a client can attach. Session- lifetime engine is what makes listen-like-debugpy possible.
Positive:
- IronPython debug has an explicit debugger, protocol, layout, and engine
contract without coupling to CPython
debugpy. - No extra NuGet/pip surface; pydevd stays unmodified source on disk.
- Breakpoint identity stays the file the user edits.
Tradeoffs:
- Two listen-on-port stacks and two VS Code attach configurations
(
debugpy5678 CPython,debugpy4567 IronPython). If 4567 is taken, the fallback port must be visible. - pyRevit-loaded Revit runs and debugs on the ScriptExecutor engine
(
full_frame: false, reused). AutoCAD / no-pyRevit use embedded 3.4.2. - Zip download needs network once per machine (or a pre-seeded AppData tree).
- pyRevit
sys.modulesis session-lifetime; user-extension modules are dropped before each Run. Embedded IPy is a long-lived Frames engine. - First-line breakpoints miss unless VS Code is already attached — same as
CPython
debugpywithoutwait_for_client. - Expanding Revit API objects in the Variables view calls
getattron throwing properties (FamilyCreate,FamilyManager, worksets). HostIpyDebugger.pyreplaces resolverprint_excwith a one-line value and swallows stderr during expand. Do not fork AppData pydevd. - net48
Path.GetFullPathrejects CLR assembly-name__file__blobs.IpyDebugger.pywrapsabspathbefore_enable_attach.
Spike is done when a live IronPython debug Run shows all of:
import pydevdon the Frames enginehasattr(sys, "_getframe")is truepydevd_constants.IS_IRONPYTHONis true (sys.platform == 'cli')- DAP listener (
HTTP_JSON_PROTOCOL+_enable_attach) - VS Code debugpy client connects to 4567
- breakpoint hit on the real
co_filename - locals visible and step-over works
Smoke (no network) before listener:
import sys, platform, pydevd
from _pydevd_bundle import pydevd_constants
# expect: cli, IronPython, _getframe True, pydevd 2.8.0,
# IS_IRONPYTHON True, CYTHON_SUPPORTED FalseAfter that spike passes:
- Product layer:
docs/product/execution.md— IronPython listen-on-4567. - Architecture:
code-execution.md—pydevdAppData layout, session engine, engine flags. - Optional later: IPy unittest debug through the same loader path.
- Debugger zip: https://github.com/fabioz/PyDev.Debugger/archive/refs/tags/pydev_debugger_2_8_0.zip
- Client: VS Code/Cursor
"type": "debugpy"attach+connect tolocalhost:4567 - Handshake:
source/DevTools.Execution/Resources/scripts/IpyDebugger.py - CPython listen:
source/DevTools.Execution/Providers/Python/PythonDebugger.cs - Embedded IPy:
source/DevTools.Execution/Providers/IronPython/IronPythonExecutor.cs - AppData root:
source/DevTools.Utilities/AppUtils.cs - pyRevit engine JSON:
source/RevitDevTool/Execution/PyRevit/PyRevitReflectionCache.cs