fix: prevent SIGABRT crash in protonvpn status from Rust local agent background threads - #19
Open
sicambria wants to merge 1 commit into
Open
Conversation
…t background threads
Core dump analysis confirmed the crash path:
status()
-> get_vpn_connector()
-> VPNConnector.initialize_state()
-> _get_current_connection()
-> Wireguard(connection_id=...)
-> _initialize_persisted_connection()
-> _async_start_local_agent_listener()
-> Listener.listen(on_status, on_error)
-> Rust tokio threads hold PyO3 Python callbacks
CLI exits -> Py_FinalizeEx -> _Py_IsFinalizing() = true
-> tokio thread dispatches callback via Python::with_gil()
-> PyEval_RestoreThread detects finalization
-> PyThread_exit_thread() -> abort() -> SIGABRT
The status command starts the local agent listener only to read the
current connection state. The listener spawns a persistent tokio
runtime whose background threads outlive the Python interpreter.
Fix (two complementary layers):
1. Explicitly stop() the AgentListener after displaying status, so
tokio tasks are dropped while the asyncio loop is still alive.
Failures are logged at debug level rather than swallowed silently.
2. Register an atexit SIGABRT guard as a safety net for any residual
Rust callbacks (e.g. init_logger) during Py_FinalizeEx. Logs a
warning to stderr before exiting cleanly so the occurrence is
still visible.
Verified: 10/10 consecutive invocations, exit code 0, zero core dumps.
sicambria
force-pushed
the
fix/sigabrt-local-agent-crash
branch
from
June 10, 2026 09:41
3a9784e to
2140650
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix
protonvpn statusSIGABRT crash from Rust local agent threadsProblem
protonvpn statuscrashes withSIGABRTand dumps a ~15 MB core when the VPN is connected. The crash originates inlocal_agent.abi3.so— the Rust native extension for ProtonVPN's local agent protocol.Environment
How to reproduce
protonvpn connect <server>(WireGuard protocol).protonvpn statuswhile connected.echo $?) and look for a new core dump (coredumpctl list | tail).On affected systems,
statusprints the connection details correctly but the process then aborts during interpreter shutdown (non-zero/abort exit code, new core dump entry).Core dump evidence
PyObject_Callfromlocal_agent.abi3.so→PyEval_RestoreThreaddetects finalization →PyThread_exit_thread→abort()Py_FinalizeEx→PyGC_Collect— normal interpreter shutdownRoot cause trace
The
statuscommand starts the local agent listener solely to read the current state. The RustListenerspawns a persistent tokio runtime with background threads that hold Python callbacks. When the one-shot CLI exits:asyncio.run()completes → Python entersPy_FinalizeEx_Py_IsFinalizing()is set to truePython::with_gil()callsPyEval_RestoreThread_Py_IsFinalizing()→ callsPyThread_exit_thread()→abort()→ SIGABRTFix
Two complementary layers:
Layer 1: Explicit listener stop (
server.py)After the status output is printed, stop the
AgentListenerif it is running. This cancels the RustListener.listen()future while the asyncio loop is still alive, giving tokio time to drain its tasks before Python shutdown.A 5-second
asyncio.wait_fortimeout prevents hanging if the Rust side is slow to respond. Failures are logged at debug level rather than silently swallowed, so cleanup can never crash the command but is still visible when debugging.Layer 2: SIGABRT safety net (
__init__.py)An
atexit-registered callback installs a SIGABRT signal handler, active only during the interpreter-shutdown window. If any remaining Rust code (e.g. theinit_loggercallback stored in the native module global state) triggers a signal duringPy_FinalizeEx, it logs a warning to stderr and converts the crash to a cleanos._exit(1)instead of a core dump.Why the order matters
status()runsWireguard._initialize_persisted_connection().stop()calledListener.listen()future cancelled; tokio tasks dropped — threads go idleasyncio.run()returnsatexitfiresPy_FinalizeEx()runsinit_loggeror any residual Rust code fires, SIGABRT is logged and converted toos._exit(1)Tests
Added unit tests in
tests/unit/commands/test_server.pycovering the newstatuscleanup path:test_status_stops_local_agent_listener_when_runningtest_status_does_not_stop_local_agent_listener_when_not_runningtest_status_succeeds_even_if_stopping_local_agent_listener_failsManual verification
Files changed
proton/vpn/cli/commands/server.pyAgentListener.stop()with 5s timeout after status displayproton/vpn/cli/__init__.pyatexitSIGABRT guard during interpreter shutdown, logs before exitingtests/unit/commands/test_server.pyKnown limitation
local_agent.abi3.sostoresproton.vpn.logging.getLoggeras a global Python callback viainit_logger(). This callback cannot be unset from Python. If Rust code emits a log during its own native module cleanup, the SIGABRT handler catches it and logs a warning. A proper fix inproton-vpn-local-agent— checkingPy_IsInitialized()before dispatching Python callbacks from tokio threads — would make the signal handler unnecessary.