Skip to content

Python bindings - #19

Open
jagerman wants to merge 11 commits into
session-foundation:devfrom
jagerman:python-bindings
Open

jagerman wants to merge 11 commits into
session-foundation:devfrom
jagerman:python-bindings

Conversation

@jagerman

Copy link
Copy Markdown
Member

Adds libquic Python bindings via pybind11.

Close packets have been best-effort: one the socket is not ready to
accept gets parked on a when_writeable callback and then thrown away
when the endpoint destroys its socket.  Until now the only way to give
them a chance to go out was to hold an Endpoint alive by hand, or to use
Network, which reaches into the private _close_conns.

close_conns(d, wait) closes as before and then blocks the caller, up to
`wait`, until every close packet it produced has been handed to the
socket.  Expiry needs no handling: what has not flushed by then is
dropped, which is what happened unconditionally before.

Completion is tracked by the lifetime of a shared close_flush rather
than by counting: a copy rides along with each close packet's send
completion callback, and the waiter is released when the last one dies.
That covers the paths where the callback is never invoked at all -- a
dropped socket, or a cancelled job queue -- so an indefinite wait cannot
hang, and it needs no guard count to avoid firing in the gap between one
connection's close completing and the next being queued.
CMake option LIBQUIC_BUILD_PYTHON (off by default) builds a pybind11
extension, and a scikit-build-core pyproject drives the same CMake for
`pip install .`.  Nothing is bound yet beyond the version.

The package is a thin compiled `seshquic._core` under a pure-Python
`seshquic`, so that the convenience layer -- keyword arguments, context
managers, futures -- can change without touching C++.
Address is exposed as-is; the flexible input forms a script wants (a
"host:port" string, a (host, port) tuple) belong in the Python layer
that will call this, not here.  RemoteAddress is deliberately not bound:
the remote pubkey reads better as a keyword argument to connect() than
as a distinct address type.

Credentials covers the three GNUTLSCreds factories plus the unencrypted
credentials, keeping the latter's deliberately unmissable naming.  Key
material is taken as any bytes-like object, and str is rejected rather
than guessed at: the caller picks the encoding, as they do for a socket.

Also extends format.sh to cover python/, and adds the GIL and lifetime
helpers everything else will be built on -- see common.hpp for why every
call into libquic has to drop the GIL first.
An Endpoint owns its own event loop thread unless handed one to share,
so a script holds one object rather than a Network and an Endpoint.

Ownership is the part worth reading twice.  Python holds the endpoint
strongly and everything below it weakly: libquic's endpoint owns its
connections, which own their streams, and a strong reference from Python
lets one outlive the owner whose destructor it then reaches into.  The
first cut did hold them strongly, and a connection kept past
endpoint.close() was a use-after-free at interpreter shutdown.  Observing
rather than owning keeps the C++ ownership as designed and turns a stale
handle into a RuntimeError; see `observed` in common.hpp.

The other recurring hazard is the GIL.  libquic's accessors mostly block
on the loop thread, so every call into it drops the GIL first, and every
callback out of it takes the GIL -- including when merely *destroying*
the held callable, which happens on the loop thread.  A callback that
raises is reported through sys.unraisablehook rather than escaping into
ngtcp2's C callback stack, where it would terminate the process.  During
interpreter finalization the callback reference is leaked deliberately
instead: CPython parks non-main threads that ask for the GIL, so trying
to release it there hangs the process at exit.

Keyword arguments drive libquic's variadic option interfaces through
their std::optional overloads, which no-op when empty, so no template
machinery is needed to make the options optional.

The Pythonic half -- blocking connect, context managers, iterating a
stream -- is added onto the bound classes from Python rather than
wrapping them, so an object handed to a callback has it too.
pyproject.toml moves into python/, pointing at the libquic tree above it
with scikit-build-core's cmake.source-dir, so `pip install ./python`
builds the extension without a Python project file sitting in the root
of a C++ one.  Black formatting (settings borrowed from session-pysogs)
runs from utils/format.sh alongside clang-format.

From the earlier pyoxquic attempt:

- py::keep_alive on connect() and open_stream(), which fixes a real
  footgun: a connection is only a weak handle on something the endpoint
  owns, so `Endpoint(...).connect(...)` had the endpoint collected, and
  the connection closed, as the expression finished.

- Holding the Python callable through a shared_ptr with a GIL-acquiring
  deleter.  libquic copies the std::function these end up inside, and
  that copy can happen off the loop thread without the GIL, which a bare
  py::object member would not survive; a shared_ptr copy is a refcount
  bump, and the GIL is needed only in the deleter.

- value_option/flag_option to go with duration_option, which between
  them cover every keyword argument fed to libquic's variadic options.

- enable_logging(), which makes libquic's own logging reachable from a
  script.  The original called logger_config, which lives in the test
  suite rather than the library, so this goes through oxen-logging's
  public add_sink/reset_level instead, with flush_logs() alongside
  because the sinks buffer.

Credentials.from_ed_keys now takes the 64-byte combined seed its
docstring promises: libquic's make_from_ed_keys documents that it
accepts one but hands it to gnutls whole, which rejects it, so the
truncation happens here.  A combined seed carries the pubkey, so a
mismatch against the pubkey argument is now an error rather than a
handshake that fails for no visible reason.

Test keys come from PyNaCl rather than cryptography: two lines instead
of six, and it is what the rest of the Session stack already uses.
The docstring has promised since it was written that the seed may be a
combined seed+pk libsodium value, of which only the first 32 bytes are
used.  Nothing ever truncated it: make_from_ed_seckey does, but
make_from_ed_keys handed the value straight to the constructor, which
embeds it in a DER template whose lengths are fixed at 32 (04 20 for the
seed's OCTET STRING, 03 21 00 for the pubkey's BIT STRING).  A 64-byte
seed therefore produced DER declaring a length it did not have, and
every such call failed with "gnutls import of raw Ed keys failed".

make_from_ed_keys now truncates, as documented.  Since the combined
value carries a pubkey of its own, one that disagrees with the pubkey
argument is rejected rather than silently ignored -- no caller can be
relying on the old behaviour there, because the old behaviour was to
throw regardless.

The constructor also checks both sizes up front, so any wrong-sized key
reaching it says which one is wrong and how long it should be instead of
surfacing as a generic gnutls import failure.
The binding truncated the combined seed itself and checked it against
the pubkey, because make_from_ed_keys did neither.  It does both now, so
this just passes the keys through and lets the std::invalid_argument
come back as a ValueError.
A bt-request stream is per-stream and symmetric: `open_bt_stream()` for
one you open, `queue_incoming_bt_stream()` for one the other end will
open, both available on either side of any connection.  Handlers are
registered per stream, so the two directions are independent.

`request()` returns a concurrent.futures.Future rather than taking a
callback the way the earlier pyoxquic attempt did.  An error response
becomes a RequestError and a timeout a RequestTimeout (which is also a
builtin TimeoutError), so a script can write `fut.result(5)` and handle
failures with except rather than by inspecting a message in a callback.
The callback form is still there underneath as `_command`, and a Future
is what asyncio.wrap_future takes if the async layer happens later.
`command()` stays separate for the fire-and-forget case, which is how
libquic distinguishes the two anyway (by whether a response callback was
given).

Which streams are bt-request streams is a protocol decision, not a
client/server one -- the quic file server makes stream 0 a bt stream and
leaves later streams as plain ones with their own framing -- so
listen()/connect() now take on_stream_construct(connection, stream_id),
returning BTRequestStream or None, with on_stream_open(stream) to
configure what was built.  Returning anything but BTRequestStream falls
through to libquic's own default rather than building a bare Stream,
because only libquic can wire that one up with the endpoint's stream
callbacks.

Two fixes fell out of testing this:

- wrap() now recovers the subclass, since callbacks receive a Stream&
  and a bt-request stream was arriving at on_stream_open without any of
  the methods that make it one.

- pybind11/stl.h moves into common.hpp.  btstream.cpp used std::optional
  without it and instantiated a different caster for the same type; the
  resulting ODR violation broke argument conversion elsewhere, including
  in files that did include it -- Endpoint became unconstructible.
Datagrams are off unless asked for, and both ends have to ask, so
`datagrams=True` on the Endpoint enables them, `datagram_splitting=True`
lets one span two QUIC packets (roughly doubling what fits, with
datagram_bufsize sizing the reassembly buffer), and
datagram_queue_limit caps what a connection will hold before dropping.
Passing any of those without datagrams=True is an error rather than
silently doing nothing.

Sending is `connection.send_datagram(data)` rather than going through
the Datagrams channel object: the channel exists in C++ to share the
IOChannel send machinery with streams, and has nothing else on it worth
exposing.  `max_datagram_size` is documented as worth re-reading before
each send, since it grows as the path MTU is discovered.

The receive callback is given the connection and the data as bytes.  The
data has to be copied there in any case: libquic warns that a datagram's
buffer is not guaranteed to outlive the callback.

opt::enable_datagrams reports a bad bufsize as std::out_of_range, which
pybind11 renders as IndexError; a bad argument is a ValueError in
Python, so that one is translated.
seshquic.aio wraps the threaded API in pure Python.  It is a separate
import: `import seshquic` does not pull it in, no event loop is needed
without it, and nothing in the threaded layer changed to accommodate it.
That request() already returns a concurrent.futures.Future is what makes
this cheap -- asyncio.wrap_future takes one directly.

Only what genuinely waits is a coroutine: connect, close, reading a
stream, and a bt-request.  Sending queues and returns, so it stays
synchronous, which is also what asyncio does with StreamWriter.write
against an awaited drain.

Callbacks are the part with a real decision in it.  Most are
notifications and are handed to the asyncio loop, so they may be
coroutine functions.  Three cannot be: on_stream_construct has to return
a type before libquic can build the stream, and on_connection and
on_stream_open are where streams get queued and handlers registered --
data can arrive as soon as they return, so deferring them onto the
asyncio loop loses whatever arrives in between.  The first draft did
defer them and the bt tests failed exactly that way, answering "endpoint
not found" for a handler that had not been registered yet.  Those three
now run on libquic's thread and reject coroutine functions with an
explanation.

Because those setup callbacks run off the asyncio loop, the wrapper
objects carry their loop rather than calling get_running_loop() when
needed, so registering a handler from one still yields a handler that
runs on the asyncio loop.  Anything not wrapped is reachable via `.raw`.
pip install . was the only instruction, which on a distro that marks
its Python externally managed fails outright, and which quietly installs
a copy that goes stale the moment the C++ is rebuilt.
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.

1 participant