Skip to content

fix(linux): bound L2CAP connect timeout - #25

Merged
stoprocent merged 1 commit into
stoprocent:mainfrom
cobell206:bound-l2cap-connect-timeout
Jul 27, 2026
Merged

stoprocent merged 1 commit into
stoprocent:mainfrom
cobell206:bound-l2cap-connect-timeout

Conversation

@cobell206

Copy link
Copy Markdown

Bound the L2CAP connect that blocks the event loop for ~40 s per BLE connect

BluetoothHciL2Socket::connect() calls ::connect() with no send timeout, on the
thread running libuv. When the peer doesn't complete that L2CAP connection it
blocks for the kernel's full L2CAP timeout — ~40 s measured — and nothing else in
the process runs meanwhile: no timers, no I/O, no callbacks.

This adds a bounded wait, default 2 s, BLUETOOTH_HCI_L2CAP_CONNECT_TIMEOUT_MS
to override, 0 for current behaviour. Nothing else changes: on failure the
socket is still closed and isConnected() still returns false, so the caller
still forwards the original LE_CREATE_CONN — which is what establishes the link
in this case anyway.

Why it's hard to spot

It doesn't look like Bluetooth. MQTT connects time out, unrelated timers fire
late — I measured a 20 s setTimeout firing at 61 s — and CPU stays idle
throughout, so it doesn't look like a busy loop either.

Evidence

Acaia Pearl scale, Raspberry Pi 4 / BCM43455, kernel 6.18.34, BlueZ 5.82, via
@abandonware/bluetooth-hci-socket 0.5.3-12 (same code).

Main thread during the stall, from /proc — in connect(2), every other thread
idle:

25248  node   wchan=bt_sock_wait_state  syscall=203

An event-loop lag probe across a connect, before and after:

before:  stalled 40812ms   connect took 41358ms
after:   stalled  1582ms   connect took  2162ms

One alternative I tried, because it's the obvious one

Keeping the socket open on timeout — so isConnected() stays true and no
duplicate LE_CREATE_CONN goes out — never connects at all. This connect()
is the attempt; when it can't complete, closing and falling through is the only
path that works. Also rejected: HCI_CHANNEL_USER=1 connects in 24–53 ms but
GATT never completes.

Scope and caveats

2 s is ~100× headroom for peripherals where this succeeds (milliseconds on an
established ACL link) while bounding the damage where it doesn't. The defect is
general — an unbounded blocking wait on the loop thread — but I've only seen one
peripheral trigger it, so I can't speak to how common that is.

Patch is against main and compiles cleanly (node-gyp rebuild, arm64 Debian
13). The runtime numbers come from the identical code in the @abandonware
package, which is what my deployment runs — so treat the build as verified here
and the behaviour as inherited rather than retested on this branch.

Happy to change the default, the env var name, or move the constant to a header.

@stoprocent

Copy link
Copy Markdown
Owner

Thanks — the ~40-second event-loop stall is valid. I reproduced the same class of blocking locally with Linux VHCI/btvirt: a pending kernel L2CAP connect held the synchronous write() call for roughly 40 seconds.

PR #22 merged after this branch was created, so this now conflicts with main. More importantly, applying the old-code timeout/fallback behavior as-is would lose the distinction introduced by #22 and can restore issue #21's duplicate controller attempt.

The combined behavior should be implemented on top of the current nonblocking connect() path:

  • successful poll() + SO_ERROR == 0: keep CONNECTED and suppress raw fallback
  • poll() readiness + nonzero SO_ERROR: keep CONNECTION_FAILED and suppress the duplicate raw fallback
  • finite poll() timeout: close/cancel the still-pending kernel L2CAP attempt, return a distinct timeout result, and allow one raw fallback
  • setup failure before EINPROGRESS: retain the existing raw fallback
  • timeout 0: retain the current unbounded wait as documented here

That preserves your important case where the kernel L2CAP path never finishes and raw HCI is required, while retaining #22's protection when the controller already completed and failed an actual attempt.

I suggest rebasing onto current main and applying the timeout to poll() rather than SO_SNDTIMEO. The regression capture should then cover three paths: reachable peer (one command), forced post-connect failure (one command, no duplicate), and pending L2CAP timeout (bounded wait followed by one raw command).

Maintainer edits are enabled, so I can also adapt the branch after the rebase if useful.

@stoprocent
stoprocent force-pushed the bound-l2cap-connect-timeout branch from fb06e67 to 9ad6a81 Compare July 27, 2026 11:17
@stoprocent stoprocent changed the title Bound the L2CAP connect that blocks the event loop for ~40 s per BLE connect fix(linux): bound L2CAP connect timeout Jul 27, 2026
@stoprocent

Copy link
Copy Markdown
Owner

Maintainer integration update:

  • Rebased the contributor commit onto the duplicate-safe connection result handling from fix(hci): prevent duplicate raw connection fallback #22, preserving Colin as the commit author.
  • Reworked the timeout around the existing nonblocking connect/poll path. Setup failure and the local deadline allow raw fallback; an explicit controller failure still suppresses a duplicate raw attempt.
  • Added strict environment parsing and documented the setting. The default is 2000 ms, 0 is unbounded, and invalid or negative values use the default.

Linux VHCI validation with two BlueZ virtual controllers:

Scenario Observed result LE Create Connection commands
no advertising peer, default write returned in 2010.9 ms and raw fallback ran 1
no advertising peer, 100 ms override write returned in 103.3 ms 1
timeout value 0 still blocked when killed after 3 s; no raw fallback 0
invalid / negative values returned in 2004.1 / 2005.7 ms 1 each
advertising peer kernel L2CAP connection completed in 14.1 ms 1
peer immediately disconnects controller path remained single-attempt 1

The Linux ARM64 native addon also compiles cleanly, and lint passes. The repository test command retains its pre-existing caught TypeError on macOS while exiting successfully; this patch does not affect that harness.

@stoprocent
stoprocent force-pushed the bound-l2cap-connect-timeout branch from 9ad6a81 to ebddbad Compare July 27, 2026 11:18
@stoprocent
stoprocent marked this pull request as draft July 27, 2026 11:20
@stoprocent

Copy link
Copy Markdown
Owner

Temporarily returning this to draft so #26 can merge first. That ensures the next socket release uses the repaired GitHub asset uploader. After #26 lands, this branch should be rebased onto main and the full matrix rerun before #25 is marked ready.

@cobell206

cobell206 commented Jul 27, 2026 •

Copy link
Copy Markdown
Author

Agreed on all counts — poll() is the right place for it. You're right that the SO_SNDTIMEO version would have undone #22's distinction and brought #21 back; I'd fetched main before #22 landed and hadn't considered the duplicate-attempt case at all.

Thanks for reproducing it on VHCI and for doing the rework. Nothing needed from me, happy for you to carry the branch.

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) <noreply@anthropic.com>
@stoprocent
stoprocent force-pushed the bound-l2cap-connect-timeout branch from ebddbad to fe20530 Compare July 27, 2026 15:05
@stoprocent
stoprocent marked this pull request as ready for review July 27, 2026 15:10
@stoprocent

Copy link
Copy Markdown
Owner

Rebased onto main after #26 merged. The updated head includes @semantic-release/github 12.0.9, and all 14 required validation/test/build checks pass across Linux, macOS, Windows, Android, ARM, and x64. Marking ready for review.

@stoprocent stoprocent left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated the timeout state transitions locally and with BlueZ VHCI, then revalidated the rebased head across the complete GitHub Actions matrix. Approved for merge.

@stoprocent
stoprocent merged commit babc8b8 into stoprocent:main Jul 27, 2026
15 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.2.8 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants