Skip to content

fix(deb): compile Python from source for AF_BLUETOOTH support - #7

Merged
impuls42 merged 1 commit into
mainfrom
fix/af-bluetooth-deb
Jul 29, 2026
Merged

fix(deb): compile Python from source for AF_BLUETOOTH support#7
impuls42 merged 1 commit into
mainfrom
fix/af-bluetooth-deb

Conversation

@impuls42

Copy link
Copy Markdown
Member

Replaces python-build-standalone with a from-source build in the Debian container so that socket.AF_BLUETOOTH is natively available in the .deb package. Also falls back to BLE if AF_BLUETOOTH is missing.

@impuls42
impuls42 force-pushed the fix/af-bluetooth-deb branch from c58508c to 7bee849 Compare July 29, 2026 22:01
@impuls42
impuls42 merged commit e231c96 into main Jul 29, 2026
1 check passed
impuls42 added a commit that referenced this pull request Jul 29, 2026
* fix(deb): address RFCOMM directly instead of rebuilding CPython

The .deb could not open an RFCOMM socket:

    D30ConnectError: this platform has no AF_BLUETOOTH; use transport 'serial'
    with rfcomm bind

#7 fixed that by compiling CPython from source against libbluetooth-dev. That
works, but it gives up the property the bundled interpreter exists for. A
python-build-standalone build links glibc and nothing else; a source build links
the build container's libssl, libcrypto, liblzma, libsqlite3, libreadline,
libncursesw and libffi, none of which are copied into /opt/labelfab and none of
which are in depends:. That is the same soname drift 33b629a set out to kill,
and it would surface far from here -- MQTT over TLS dying on some target distro.
It also puts a full CPython build inside the QEMU-emulated arm64 release leg.

None of it is necessary. AF_BLUETOOTH is a kernel feature. CPython gates only
two compile-time things on bluetooth/bluetooth.h: the constants, and the
sockaddr_rc marshalling inside getsockaddrarg(). socket(31, SOCK_STREAM, 3) is
a plain syscall that works on any build -- only the address encoding is missing,
and that is nine bytes of stable kernel ABI.

So device/_rfcomm.py encodes sockaddr_rc and hands it to libc connect(2). Past
connect, an RFCOMM socket is an ordinary stream, so sendall/recv/settimeout/
close are untouched and the reader thread is unaffected. Connect goes
non-blocking + select + SO_ERROR, the way the socket module does it internally,
so connect_timeout_s still means something.

Verified against the printer from a python-build-standalone interpreter with no
socket.AF_BLUETOOTH: link established, and CHIP_TYPE/FIRMWARE_VERSION/SERIAL/
PAPER_STATE decode to firmware 2.1.2, serial Q223P4C31420105, hot_state 0xA8 --
which is what HARDWARE-NOTES predicts. Then again from the built .deb installed
in a bookworm container, where the bundled interpreter still links only glibc.

Also drops DEFAULT_TRANSPORT. It picked afbluetooth or ble by sniffing
hasattr(socket, 'AF_BLUETOOTH'), which meant one agent.toml behaved differently
depending on how the python reading it had been compiled, and it dragged the
config layer into importing the device layer. The transport default is a literal
again. The platform guard is now sys.platform, which is the honest question:
family 31 is not RFCOMM off Linux.

Tests: byte-exact vectors for the encoder (address reversal and the trailing pad
are silent to get wrong -- a short sockaddr returns EINVAL, which reads like a
caller bug), a differential test binding through both the shim and the socket
module and comparing raw getsockname, and a hardware-marked end-to-end behind
LABELFAB_TEST_MAC.

* fix(device): non-retryable config error for a bad MAC; share the transport names

Review follow-ups on #8.

A malformed device.mac reached _rfcomm.connect and raised ValueError. The worker
only catches D30Error, so that took down the print loop rather than failing the
one job, and the socket opened a line earlier was never closed. It is now a
D30ConfigError with retryable=False, and the fd is closed on the way out.
Deliberately not a D30ConnectError: that one means "the printer is away", which
the worker is right to retry -- a typo in an address is not, and retrying it
would just hide the typo behind a reconnect loop.

DEFAULT_TRANSPORT comes back, but as a plain literal alongside TRANSPORTS rather
than the hasattr() sniff that #8 removed. The CLI's choices list and the config's
regex were two copies of the same four names; they now come from one place, so a
new transport is named once. cli.py imports the device layer at module scope for
this, which it otherwise avoids to keep `preview` snappy -- measured at ~23ms on
a ~160ms import, which is not enough to justify a function-local import.

build-deb.sh pins the python-build-standalone release instead of following
latest. Rebuilding an old tag now reproduces the interpreter it shipped with, and
a PBS release cut between the amd64 and arm64 legs of one matrix can no longer
give them different interpreters. PBS_RELEASE overrides it.

EINTR keeps its place beside EINPROGRESS/EALREADY, now with a comment saying why:
an interrupted connect still completes asynchronously, so waiting for writability
is correct and calling connect() again would return EALREADY.

Tests cover both halves of the MAC bug -- the error type and retryable flag, and
that twenty failed opens do not leak fds.

* fix(device): export D30ConfigError alongside the other error classes

Every other D30 error is both imported into the device package and listed in
__all__; this one was not. Nothing depended on it -- the worker dispatches on the
D30Error base -- but the public surface should not have a hole in it.

* test(rfcomm): preflight the kernel-bluetooth skip instead of catching around it

The bad-MAC test caught OSError to skip on hosts with no bluetooth module, but
open() wraps EAFNOSUPPORT into a D30ConnectError, which is not an OSError. So on
exactly those hosts the guard missed, pytest.raises saw a non-matching exception,
and the test failed rather than skipping -- which is what CI does, since GitHub
runners have no bluetooth module loaded.

All three kernel-touching tests now share one preflight that opens and closes a
socket first, the approach the fd-leak test already used. Verified by forcing
EAFNOSUPPORT locally: 12 passed, 4 skipped, nothing failed.

* fix(agent): honour retryable on the connect path too

D30ConfigError was introduced as non-retryable, and its docstring said so, but the
worker's connect loop caught the D30Error base and retried unconditionally -- so a
malformed device.mac still burned all three attempts and ~13s of backoff per job
before failing. The print path a few lines below already gated on exc.retryable;
the connect path only looked like it did.

Two tests pin both directions: a permanent failure gets one attempt, a transient
one still gets the full budget. Verified the first fails (3 != 1) without the
one-line change.
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