Skip to content

python: let KeyboardInterrupt and SystemExit out of a callback (ibx#270) - #390

Open
userFRM wants to merge 1 commit into
deepentropy:mainfrom
userFRM:fix/python-callback-exceptions
Open

python: let KeyboardInterrupt and SystemExit out of a callback (ibx#270)#390
userFRM wants to merge 1 commit into
deepentropy:mainfrom
userFRM:fix/python-callback-exceptions

Conversation

@userFRM

@userFRM userFRM commented Jul 31, 2026

Copy link
Copy Markdown

Problem

Every Python callback was invoked through a macro that caught whatever it raised, logged it, and carried on. That is right for an ordinary exception — one bad callback should not kill the dispatch loop — but it applied to KeyboardInterrupt and SystemExit too.

So Ctrl-C pressed while a callback was running was logged and discarded, and a callback raising SystemExit could not stop run().

What this changes

An exception that derives from BaseException but not Exception is re-raised instead of logged. That is exactly the set Python reserves for control flow — KeyboardInterrupt, SystemExit, GeneratorExit — and it is the same line ibapi draws. Ordinary exceptions are still caught and logged, unchanged.

Every call site sits directly in dispatch_once's body, so the re-raise unwinds through its PyResult<()>, through run()'s ?, and back to the Python caller as the original exception. Verified by walking the brace structure of the function rather than by grep: the function contains ten closure bodies, and no call site is inside one — a return in a closure would have exited the closure and swallowed the interrupt anyway.

The restore-then-PyErr_Clear pair on the swallow path is removed. It nets out to dropping the error, which is what letting it fall out of scope does.

Verification

cargo check --offline --lib --features python is clean, and the full gate passes.

Not run: cargo test --lib --features python, which does not link in this tree — a stale test blocks compilation and libpython is then missing (#381). The propagation path is therefore established by reading the call-site placement, the return-type plumbing, and pyo3's exception hierarchy rather than by executing it.

Closes #270.

call_wrapper! in src/python/compat/client/dispatch.rs wrapped every Python
wrapper call and, on error, logged the exception then discarded it
unconditionally via e.restore(py) followed by PyErr_Clear() — a pair that
nets out to the same as dropping e, since restoring an exception immediately
before clearing it leaves nothing set. KeyboardInterrupt and SystemExit
derive from BaseException rather than Exception precisely so that blanket
handlers do not eat them, and this handler ate them along with everything
else.

Two consequences followed. CPython delivers Ctrl-C by raising
KeyboardInterrupt in whatever frame is executing when the signal arrives; if
that frame was a user callback invoked through call_wrapper!, the exception
surfaced as the call_method error and was discarded before run()'s
py.check_signals() ever ran, so the interrupt was only caught when it landed
in the wait window between dispatch iterations. And a callback raising
SystemExit — the mechanism ibapi's own dispatch loop honors so error() or
order_status() can stop the client — was logged and swallowed instead of
terminating run().

The macro now checks e.is_instance_of::<pyo3::exceptions::PyException>(py)
before deciding what to do with the error. Anything deriving from Exception
is logged and dropped, same as before. Anything that is a BaseException but
not an Exception — KeyboardInterrupt, SystemExit, and anything else in that
category — is returned instead. Every call_wrapper! invocation in
dispatch_once sits directly in the function body rather than inside a
closure, so that return unwinds through dispatch_once's PyResult<()>,
through run()'s ? on the dispatch_once call, and back to the Python caller
as the original exception, letting run() stop the same way ibapi's dispatch
loop does. The restore/PyErr_Clear pair is gone; letting e drop at the end
of the swallow branch already discards it.

cargo check --offline --lib --features python is clean and the full gate
suite passes (GATES CLEAN), with the two config::expiry_tests tzdata
failures that are expected in this environment. cargo test --lib --features
python does not link here per ibx#381, and no Python-level test was run
either — there is no built extension module in this tree and building one
would hit the same linkage gap. The propagation path was checked by reading
every call_wrapper! call site to confirm none sit inside a closure, and
against pyo3 0.29's exception hierarchy, where PyKeyboardInterrupt and
PySystemExit both derive from PyBaseException rather than PyException.

Closes deepentropy#270.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

python: callback exceptions are swallowed wholesale, so Ctrl-C during a callback is lost and SystemExit cannot stop the loop

1 participant