From e33e46d3b41c1b57a7b4f396b0eba1457b4359df Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:57:55 -0500 Subject: [PATCH] Queue Bluetooth writes so fast typing no longer drops characters --- src/transports/bluetooth.js | 48 +++++++++++++++++++++++++++++++++---- test/suites/ble_write.js | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 test/suites/ble_write.js diff --git a/src/transports/bluetooth.js b/src/transports/bluetooth.js index b436e7f..57f34e7 100644 --- a/src/transports/bluetooth.js +++ b/src/transports/bluetooth.js @@ -280,9 +280,49 @@ export class WebBluetooth extends Transport { await this._dropStream() } - async writeBytes(data) { - //await this.tx.writeValueWithoutResponse(data) - await this.tx.writeValue(data) - await sleep(1) + /* + * Web Bluetooth allows one GATT operation at a time on a link: a second + * writeValue() while one is in flight fails with "GATT operation already in + * progress", and the bytes are lost. Typing at the REPL calls write() for every + * key, so writes arrive while the last one is still on the air. They wait in + * a queue instead, and whatever is waiting when the link frees up goes out + * together, up to one characteristic's worth. + */ + writeBytes(data) { + return new Promise((resolve, reject) => { + this._txQueue = this._txQueue || [] + this._txQueue.push({ data, resolve, reject }) + if (!this._txBusy) { this._drainTx() } + }) + } + + async _drainTx() { + this._txBusy = true + try { + while (this._txQueue.length) { + const limit = Math.max(1, this.writeChunk) + const batch = [] + let size = 0 + while (this._txQueue.length && + (batch.length === 0 || size + this._txQueue[0].data.byteLength <= limit)) { + const item = this._txQueue.shift() + batch.push(item) + size += item.data.byteLength + } + const out = new Uint8Array(size) + let at = 0 + for (const item of batch) { out.set(item.data, at); at += item.data.byteLength } + try { + //await this.tx.writeValueWithoutResponse(out) + await this.tx.writeValue(out) + await sleep(1) + for (const item of batch) { item.resolve() } + } catch (err) { + for (const item of batch) { item.reject(err) } + } + } + } finally { + this._txBusy = false + } } } diff --git a/test/suites/ble_write.js b/test/suites/ble_write.js new file mode 100644 index 0000000..35a1988 --- /dev/null +++ b/test/suites/ble_write.js @@ -0,0 +1,47 @@ +/* + * The Bluetooth transport must never start a GATT write while another is still + * in flight, however fast the REPL is typed into. + */ +import { assert } from 'chai' + +Object.defineProperty(globalThis.navigator, 'bluetooth', { value: {}, configurable: true }) +const { WebBluetooth } = await import('../../src/transports/bluetooth.js') + +function makeTransport(chunk) { + const t = new WebBluetooth() + t.writeChunk = chunk + const sent = [] + let inFlight = false + t.tx = { + async writeValue(v) { + if (inFlight) { throw new DOMException('GATT operation already in progress.', 'NetworkError') } + inFlight = true + await new Promise(r => setTimeout(r, 5)) + sent.push(Array.from(v)) + inFlight = false + }, + } + return { t, sent } +} + +describe('Bluetooth transport writes', function () { + it('keeps every keystroke typed back to back', async function () { + const { t, sent } = makeTransport(20) + const errors = [] + t.handleWriteError = (e) => { errors.push(e.message); return true } + const text = 'print("hello world")\r' + await Promise.all([...text].map(c => t.write(c))) + assert.deepEqual(errors, []) + assert.equal(String.fromCharCode(...sent.flat()), text) + }) + + it('batches waiting keystrokes without exceeding the characteristic size', async function () { + const { t, sent } = makeTransport(8) + t.handleWriteError = () => true + const text = 'abcdefghijklmnopqrstuvwxyz' + await Promise.all([...text].map(c => t.write(c))) + assert.equal(String.fromCharCode(...sent.flat()), text) + assert.isTrue(sent.every(s => s.length <= 8)) + assert.isBelow(sent.length, text.length) + }) +})