Fix/routes reload crash - #3558
Conversation
BaseHTTPRequestHandler logs one access-log line to sys.stderr for every request, from send_response(). pyRevit directs stderr to its script output console, a WPF window that can only be created on Revit's STA UI thread. Requests are served on threads that are never STA, so when that console does not exist yet the write attempts to create it and raises "The calling thread must be STA". Unhandled on a request thread, that terminates the Revit process. Revit disappears with no dialog, leaving only a .NET Runtime event (ID 1026) whose stack shows the output console being constructed under PythonOps.PrintWithDest. That stack describes the error reporter rather than the error, which makes it easy to misread. Route request logging through mlogger, which already suppresses failures from the logging service and is therefore safe to call off the UI thread. log_error() and log_request() both reach stderr through log_message(), so overriding it covers all request logging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ThreadingMixIn guards the request itself but not the cleanup that follows it. Stopping the server closes sockets underneath requests that are still in flight, so that cleanup raises on an already closed socket and the exception escapes the thread without ever reaching handle_error. An exception escaping a request thread is fatal: it is printed to stderr, which pyRevit directs to a WPF console that cannot be created off the STA UI thread, and the resulting error terminates Revit. This is reproducible by reloading pyRevit while a request is in flight. Guard the whole request path, and report failures through mlogger rather than the inherited traceback print to stderr, which is the same fatal route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each server is started twice during activation, so two accept loops run against one socket while only the second is tracked. The untracked loop is never joined when the server is stopped. Make start() a no-op when the accept loop is already running, and guard the thread body so an exception in it cannot escape and terminate Revit. Note this does not by itself resolve the leaked listener seen after repeated reloads: stopping a server does close its own socket, but a second server ends up bound to the port that nothing stops. That appears to originate above this module, in how a reload re-imports and re-activates the routes stack, and is reported separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
PR Summary:
- Fixes a crash where reloading pyRevit while the Routes server is running kills Revit on the next request.
- Three defects in
server.pyare addressed: (1)log_messagenow routes throughmloggerinstead ofstderr(WPF console creation off the STA thread), (2)process_request_threadfully guardsfinish_request+shutdown_requestso no exception escapes a serving thread, (3)start()is a no-op when the accept loop is already running, preventing a doubleserve_foreverfrom__init__+activate_server(). - All changes are additive/defensive, IronPython 2.7 compatible, and verified against Revit 2026.
Review Summary:
The three fixes are well-isolated, correctly target the identified defects, and the reasoning is sound — the log_message override covers all access logging, the process_request_thread guard closes the unguarded cleanup path, and the start() no-op prevents the leaked second accept loop. The only issues found are two except Exception: pass blocks in process_request_thread that silently swallow exceptions without logging, which conflicts with the repo's coding guideline that catch blocks must log at minimum. Both are quick one-line mlogger.debug() additions that preserve the defensive intent while leaving a diagnostic trail. Knowledge utilized: repo coding guidelines (empty catch block logging requirement), IronPython 2.7 compatibility checklist, and the exception/logging visibility review checklist.
Suggestions
Cover ThreadedHttpServer.process_request_thread: the per-request cleanup raising on an already closed socket, failures while serving the request, a failure inside handle_error itself, and the combination of all three. Each is contained, cleanup always runs, and nothing reaches stderr. Also covers handle_error reporting through mlogger instead of stderr, and the guard holding when the method runs on an actual thread.
|
Added unit tests for the request-path guard in
Verification:
The file uses the |
The guards in process_request_thread swallowed exceptions with no diagnostic trail. A cleanup failure on a closed socket is the exact condition this path exists to contain, so discarding its trace removes the evidence needed to recognise a different failure later. Log both through mlogger, which cannot raise from a serving thread, and keep swallowing so nothing escapes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a crash where reloading pyRevit while the Routes server is running would kill the Revit process on the next request. The root cause is that requests are served on non-STA threads while pyRevit redirects stderr to a WPF output console that can only be created on Revit's STA UI thread; any exception printed from a serving thread raises The calling thread must be STA and terminates the process. The fix hardens pyrevitlib/pyrevit/routes/server/server.py against three distinct defects and adds unit tests for the threaded request path.
Changes:
- Override
HttpRequestHandler.log_messageto route request logging throughmloggerinstead ofstderr. - Override
ThreadedHttpServer.handle_errorandprocess_request_threadso no exception (including per-request socket cleanup after shutdown) escapes a serving thread, and nothing reachesstderr. - Make
RoutesServer.start()idempotent so activation no longer spawns a second, untrackedserve_foreverloop on the same socket; the loop thread is also guarded.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
pyrevitlib/pyrevit/routes/server/server.py |
Adds defensive overrides (log_message, handle_error, process_request_thread) and an idempotent, guarded start() to keep serving-thread failures off stderr and prevent duplicate accept loops. |
pyrevitlib/pyrevit/unittests/test_routes_server_threading.py |
New unit tests covering the happy path, contained serving/cleanup/error-reporting failures, stderr isolation, and handle_error logging behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The log_message override is the fix for the stderr crash, but it was the only one of the three overrides with nothing pinning it. Removing it makes the inherited implementation write every request line to the script output console from a non-STA thread, which is the crash this PR exists to stop. Cover the override and the two inherited entry points that reach it, so a served request and a rejected one are both accounted for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fix Routes server killing Revit on reload
Description
Reloading pyRevit while the Routes server is running leaves the server dead, and
the next request terminates the Revit process.
Three defects in
pyrevitlib/pyrevit/routes/server/server.pycombine to producethis. They share one hazard: requests are served on threads that are never STA,
while pyRevit directs
stderrto its script output console — a WPF window thatcan only be created on Revit's STA UI thread. Anything printed from a serving
thread therefore tries to build a WPF window off the UI thread and raises
The calling thread must be STA. Unhandled on a thread, that terminates theprocess.
Revit vanishes with no dialog, leaving only an Application event log entry
(
.NET Runtime, ID 1026):That stack describes the error reporter, not the error.
PrintWithDestinvoked directly from
ThreadObj.Start(), with no user frame between, means anexception escaped a serving thread and IronPython was printing it — and the
printing is what kills the process.
Reproduction
Verified on Revit 2026 against an unmodified
developbuild:The request returns nothing and Revit terminates with the event above. Measured
after step 2 on
develop: zero listeners on the Routes port. Opening a modelor running a pyRevit tool first is not required; both were tested and made no
difference.
The three defects
Each is a separate commit and can be reviewed independently. The measurements
referenced here are in the Testing section.
1. Request logging writes to stderr
BaseHTTPRequestHandler.log_message()writes an access-log line tosys.stderrfor every request, reached from
send_response()vialog_request(). That isthe hazard above, on the response path of every request.
Request logging now goes through
mlogger, whose_emitalready suppressesfailures from the logging service and is therefore safe to call off the UI
thread.
log_error()andlog_request()both reach stderr throughlog_message(), so overriding that one method covers all of it.Measured: with defects 2 and 3 fixed but this one left in place, a request
after a reload returns no HTTP response at all — the connection is accepted
and closed in ~30 ms with an empty body, because the exception is raised part
way through writing the response. Adding this fix, and changing nothing else,
returns a correct 503 with a JSON body. Runs 5 and 6 below differ by this
commit alone.
2. Exceptions escape serving threads
ThreadingMixIn.process_request_threadguards the request itself but not theper-request cleanup that follows. Stopping the server closes sockets while
requests are in flight, so that cleanup raises on an already closed socket and
the exception escapes the thread without ever reaching
handle_error. Thewhole request path is now guarded, and
handle_errorno longer prints atraceback to stderr.
Measured: this is the defect the reproduction above triggers. Both captured
crashes show
PrintWithDestcalled directly fromThreadObj.Start(), i.e. anexception escaping a thread rather than the
log_messagepath. With this fixapplied, the same sequence leaves Revit running.
3. Two accept loops per server
RoutesServer.__init__callsself.start(), andactivate_server()callsstart()again on the instance it just constructed. Twoserve_foreverloopsrun against one socket while only the second is tracked, so the first is never
joined on shutdown.
start()is now a no-op when the loop is already running.Measured: after a reload,
developleaves zero listeners on the Routesport — the server does not come back. This branch leaves one, serving
normally, across three consecutive reloads. The double call itself is plain in
the source. The listener measurement is for the branch as a whole; this commit
was not tested in isolation.
Checklist
black --checkreports the file unchanged.ruff checkreports 12 violations after this change versus 13 before —all pre-existing
D102s on untouched methods; one is resolved becausestart()gained a docstring. No new violations.
All added code is IronPython 2.7 compatible: no f-strings, annotations, or
py3-only syntax.
Thread.is_alive()is available in 2.6+.Testing
Revit 2026. Every run used the same sequence — launch, reload pyRevit, issue a
request — varying only the contents of
server.py. A client extension thatpatches these same methods at runtime was disabled throughout so it could not
mask results. No model was open unless stated.
developdevelopA 503 is the correct response when no model is open, so it indicates a fully
served request.
On this branch, across three consecutive reloads with a continuous request
stream running, the endpoint returned to service every time and Revit stayed
responsive. No
.NET Runtime1026 events were recorded during any run carryingthis branch.
With a model loaded on this branch, every route exposed by the client returned
200, exercising both the GET and POST paths and a binary response:
/status//model_info//list_levels//list_views//current_view_info//list_family_categories//list_families/(POST)/current_view_elements/(POST)/get_view/Requests were issued sequentially;
RequestHandlerholds request state inmodule-level singletons, so concurrent requests overwrite one another
independently of this change.
Additional Notes
shutdown()to stop theloop before closing the socket makes
HTTPServer.shutdown()block the UIthread waiting for an acknowledgement, so the reload hangs instead of
crashing. Calling
server_close()during teardown closes a socket that maystill be serving a request, and terminated Revit when tried.