From fe205301d6bdf56f6ed9ca84d80f94c589a0d4e0 Mon Sep 17 00:00:00 2001 From: Colin Bell Date: Mon, 27 Jul 2026 06:22:39 -0400 Subject: [PATCH] fix(linux): bound L2CAP connect timeout BluetoothHciL2Socket::connect() runs on the thread driving libuv. A peer that does not complete the kernel L2CAP connection can otherwise block the event loop for roughly 40 seconds. Use a two-second poll deadline by default. Allow BLUETOOTH_HCI_L2CAP_CONNECT_TIMEOUT_MS to override it, and let 0 restore the unbounded behavior. A local deadline closes the pending kernel socket before raw HCI fallback, while an explicit controller failure still suppresses a duplicate attempt. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 13 ++++++- include/BluetoothHciL2Socket.h | 7 ++-- src/BluetoothHciL2Socket.cpp | 65 ++++++++++++++++++++++++++++++---- src/BluetoothHciSocket.cpp | 7 ++++ 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f683774..7f8bae7 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,18 @@ Set ```BLUETOOTH_HCI_SOCKET_FORCE_USB``` environment variable: sudo BLUETOOTH_HCI_SOCKET_FORCE_USB=1 node .js ``` +#### Bound the Linux L2CAP connect wait + +The Linux kernel L2CAP workaround waits up to 2 seconds for a connection by +default. Override the limit with `BLUETOOTH_HCI_L2CAP_CONNECT_TIMEOUT_MS`: + +```sh +sudo BLUETOOTH_HCI_L2CAP_CONNECT_TIMEOUT_MS=5000 node .js +``` + +Set the value to `0` to restore the kernel's unbounded connect wait. Invalid or +negative values use the 2-second default. + ### FreeBSD Disable automatic loading of the default Bluetooth stack by putting [no-ubt.conf](https://gist.github.com/myfreeweb/44f4f3e791a057bc4f3619a166a03b87) into ```/usr/local/etc/devd/no-ubt.conf``` and restarting devd (```sudo service devd restart```). @@ -312,4 +324,3 @@ set BLUETOOTH_HCI_SOCKET_USB_PID=0x065a node .js ``` - diff --git a/include/BluetoothHciL2Socket.h b/include/BluetoothHciL2Socket.h index 5e6e113..e3ef3f6 100644 --- a/include/BluetoothHciL2Socket.h +++ b/include/BluetoothHciL2Socket.h @@ -10,7 +10,8 @@ class BluetoothHciSocket; enum class BluetoothHciL2ConnectResult { SETUP_FAILED, CONNECTED, - CONNECTION_FAILED + CONNECTION_FAILED, + CONNECTION_TIMED_OUT }; // Bluetooth HCI L2CAP Socket class @@ -40,7 +41,9 @@ class BluetoothHciL2Socket { * * SETUP_FAILED means no controller connection was requested, so the caller * may safely use another transport. CONNECTION_FAILED means the kernel did - * request a controller connection and reported its failure. + * request a controller connection and reported its failure, so another raw + * attempt would be a duplicate. CONNECTION_TIMED_OUT means the kernel socket + * was closed without a result, so the caller may fall back to raw HCI. */ BluetoothHciL2ConnectResult connect(); diff --git a/src/BluetoothHciL2Socket.cpp b/src/BluetoothHciL2Socket.cpp index 6056dec..ad1aa1a 100644 --- a/src/BluetoothHciL2Socket.cpp +++ b/src/BluetoothHciL2Socket.cpp @@ -1,4 +1,7 @@ #include +#include +#include +#include #include #include #include @@ -11,6 +14,51 @@ #include "BluetoothHciL2Socket.h" #include "BluetoothHciSocket.h" +namespace { +constexpr int L2CAP_CONNECT_TIMEOUT_MS_DEFAULT = 2000; + +int getL2capConnectTimeoutMs() { + const char* timeoutEnv = std::getenv("BLUETOOTH_HCI_L2CAP_CONNECT_TIMEOUT_MS"); + if (timeoutEnv == nullptr) return L2CAP_CONNECT_TIMEOUT_MS_DEFAULT; + + errno = 0; + char* end = nullptr; + const long timeoutMs = std::strtol(timeoutEnv, &end, 10); + if (errno == ERANGE || end == timeoutEnv || *end != '\0' || + timeoutMs < 0 || timeoutMs > INT_MAX) { + return L2CAP_CONNECT_TIMEOUT_MS_DEFAULT; + } + + return static_cast(timeoutMs); +} + +int waitForConnect(int socket, int timeoutMs) { + struct pollfd descriptor = { socket, POLLOUT, 0 }; + + if (timeoutMs == 0) { + int pollResult; + do { + pollResult = poll(&descriptor, 1, -1); + } while (pollResult < 0 && errno == EINTR); + return pollResult; + } + + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(timeoutMs); + int remainingMs = timeoutMs; + + while (true) { + const int pollResult = poll(&descriptor, 1, remainingMs); + if (pollResult >= 0 || errno != EINTR) return pollResult; + + const auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()).count(); + if (remaining <= 0) return 0; + remainingMs = static_cast(remaining); + } +} +} + BluetoothHciL2Socket::BluetoothHciL2Socket(BluetoothHciSocket* parent, const bdaddr_t* bdaddr_src, uint8_t src_type, @@ -81,13 +129,16 @@ BluetoothHciL2ConnectResult BluetoothHciL2Socket::connect() { } // EINPROGRESS confirms that the kernel accepted the request and initiated - // the controller connection. Preserve the previous synchronous behavior, - // but retain this distinction if the controller ultimately reports failure. - struct pollfd descriptor = { this->_socket, POLLOUT, 0 }; - int pollResult; - do { - pollResult = poll(&descriptor, 1, -1); - } while (pollResult < 0 && errno == EINTR); + // the controller connection. Bound the synchronous wait because connect() + // runs on the thread driving libuv. A timeout closes the kernel socket before + // allowing the caller to fall back to the original raw HCI command. + const int pollResult = waitForConnect(this->_socket, getL2capConnectTimeoutMs()); + + if (pollResult == 0) { + close(this->_socket); + this->_socket = -1; + return BluetoothHciL2ConnectResult::CONNECTION_TIMED_OUT; + } int connectError = 0; socklen_t connectErrorLength = sizeof(connectError); diff --git a/src/BluetoothHciSocket.cpp b/src/BluetoothHciSocket.cpp index 999df40..3cdaf24 100644 --- a/src/BluetoothHciSocket.cpp +++ b/src/BluetoothHciSocket.cpp @@ -357,6 +357,13 @@ bool BluetoothHciSocket::kernelConnectWorkArounds(char * data, int length) { return false; } + if (connectResult == BluetoothHciL2ConnectResult::CONNECTION_TIMED_OUT) { + // Closing the timed-out kernel socket cancels its pending attempt; + // forward the original command through the raw HCI socket. + this->_l2sockets_connecting.erase(bdaddr_dst); + return false; + } + if (connectResult == BluetoothHciL2ConnectResult::CONNECTION_FAILED) { // The kernel already sent the controller connection command. Do not // fall through to Write() and create a second raw HCI attempt.