Skip to content

Commit ba2f184

Browse files
committed
fix: queue waiting BLE probes and rotate completed cache
1 parent effcf6c commit ba2f184

2 files changed

Lines changed: 72 additions & 5 deletions

File tree

src/transport/ReactNativeBleTransport.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,49 @@ describe('ReactNativeBleTransport', () => {
702702
expect(secondConnected.cancelConnection).not.toHaveBeenCalled();
703703
});
704704

705+
it.each(['android', 'ios'] as const)('queues a target behind four active probes on %s', async (platform) => {
706+
let callback!: (error: Error | null, value: Device | null) => void;
707+
const releases: (() => void)[] = [];
708+
const blockers = Array.from({ length: 4 }, (_, index) => device({
709+
id: `other-${index}`, name: 'Switchify PC',
710+
discoverAllServicesAndCharacteristics: jest.fn(() => new Promise<Device>((resolve) => {
711+
releases.push(() => resolve(device()));
712+
})),
713+
}));
714+
const target = device({ id: 'target', name: 'Switchify PC', readCharacteristicForService: jest.fn(async () => ({
715+
value: fromByteArray(new TextEncoder().encode('{"protocolVersion":1,"desktopId":"wanted"}')),
716+
} as Characteristic)) });
717+
const transport = new ReactNativeBleTransport(manager({ startDeviceScan: jest.fn((_u, _o, cb) => { callback = cb; }) }), platform);
718+
const resolving = transport.resolveAndConnect('wanted');
719+
await waitFor(() => !!callback);
720+
blockers.forEach((peer) => callback(null, peer));
721+
await waitFor(() => releases.length === 4);
722+
callback(null, target);
723+
expect(target.isConnected).not.toHaveBeenCalled();
724+
releases.forEach((release) => release());
725+
await expect(resolving).resolves.toMatchObject({ desktopId: 'wanted' });
726+
expect(target.isConnected).toHaveBeenCalledTimes(1);
727+
await transport.disconnect();
728+
});
729+
730+
it('retains the newest Windows address by evicting the oldest at capacity', async () => {
731+
let callback!: (error: Error | null, value: Device | null) => void;
732+
const found = jest.fn();
733+
const transport = new ReactNativeBleTransport(manager({ startDeviceScan: jest.fn((_u, _o, cb) => { callback = cb; }) }), 'android');
734+
const stop = transport.scan(found, jest.fn());
735+
const peers = Array.from({ length: 257 }, (_, index) => device({ id: `windows-${index}` }));
736+
for (const [index, peer] of peers.entries()) {
737+
callback(null, peer);
738+
await waitFor(() => found.mock.calls.length === index + 1);
739+
}
740+
callback(null, peers[256]!);
741+
expect(peers[256]!.isConnected).toHaveBeenCalledTimes(1);
742+
callback(null, peers[0]!);
743+
expect(peers[0]!.isConnected).toHaveBeenCalledTimes(2);
744+
stop();
745+
await transport.disconnect();
746+
});
747+
705748
it('waits for cancelled discovery probe cleanup before a real connection', async () => {
706749
let scanCallback!: (error: Error | null, value: Device | null) => void;
707750
let releaseDiscovery!: (value: Device) => void;

src/transport/ReactNativeBleTransport.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,16 @@ export class ReactNativeBleTransport implements BleTransport {
4646
scan(onDesktop: (desktop: DiscoveredDesktop) => void, onError: (error: Error) => void): Unsubscribe {
4747
const operation = ++this.#operation;
4848
let active = true;
49-
this.#managerOrCreate().startDeviceScan([BLE_UUIDS.service], null, (error, device) => {
49+
const waiting = new Map<string, Device>();
50+
const onAdvertisement = (error: Error | null, device: Device | null) => {
5051
if (!active || operation !== this.#operation) return;
5152
if (error) { onError(error); return; }
52-
if (!device || this.#scanDevices.has(device.id) || this.#scanKeys.has(this.#scanKey(device)) || this.#scanDevices.size >= 4) return;
53+
if (!device || this.#scanDevices.has(device.id) || this.#scanKeys.has(this.#scanKey(device))) return;
54+
if (this.#scanDevices.size >= 4) {
55+
if (waiting.size < 32) waiting.set(device.id, device);
56+
return;
57+
}
58+
waiting.delete(device.id);
5359
const scanKey = this.#scanKey(device);
5460
let retainCompletedKey = false;
5561
this.#scanDevices.set(device.id, device);
@@ -58,20 +64,28 @@ export class ReactNativeBleTransport implements BleTransport {
5864
// Suppress repeats only for this peripheral. A rotated address must be
5965
// probed again: neither a shared nor a cached name establishes identity.
6066
// Cap retained keys so long scans cannot accumulate unbounded state.
61-
retainCompletedKey = desktop?.platform === 'windows' && this.#scanKeys.size <= 256;
67+
retainCompletedKey = desktop?.platform === 'windows';
68+
if (active && operation === this.#operation && retainCompletedKey && this.#scanKeys.size > 256) {
69+
const oldest = [...this.#scanKeys].find((key) => ![...this.#scanDevices.values()].some((peer) => this.#scanKey(peer) === key));
70+
if (oldest) this.#scanKeys.delete(oldest);
71+
}
6272
if (active && operation === this.#operation && desktop) onDesktop(desktop);
6373
}).catch(() => undefined).finally(() => {
6474
if (operation === this.#operation) {
6575
this.#scanDevices.delete(device.id);
6676
if (!retainCompletedKey) this.#scanKeys.delete(scanKey);
77+
const next = waiting.values().next().value;
78+
if (next) onAdvertisement(null, next);
6779
}
6880
});
6981
this.#scanTasks.add(task);
7082
void task.finally(() => this.#scanTasks.delete(task));
71-
});
83+
};
84+
this.#managerOrCreate().startDeviceScan([BLE_UUIDS.service], null, onAdvertisement);
7285
return () => {
7386
if (!active) return;
7487
active = false;
88+
waiting.clear();
7589
if (operation === this.#operation) this.#operation += 1;
7690
this.#managerOrCreate().stopDeviceScan();
7791
this.#cancelNativeOperations();
@@ -98,10 +112,12 @@ export class ReactNativeBleTransport implements BleTransport {
98112
let active = true;
99113
let claimedDeviceId: string | null = null;
100114
let cancellation: Promise<void> | null = null;
115+
const waiting = new Map<string, Device>();
101116
const succeed = (desktop: DiscoveredDesktop) => {
102117
if (!active) return;
103118
this.#recordStage('resolution', 'succeeded', operation);
104119
active = false;
120+
waiting.clear();
105121
clearTimeout(timer);
106122
this.#managerOrCreate().stopDeviceScan();
107123
this.#scanKeys.clear();
@@ -112,6 +128,7 @@ export class ReactNativeBleTransport implements BleTransport {
112128
if (!active) return cancellation ?? Promise.resolve();
113129
this.#recordStage('resolution', outcome, operation);
114130
active = false;
131+
waiting.clear();
115132
clearTimeout(timer);
116133
this.#managerOrCreate().stopDeviceScan();
117134
const probes = [...this.#scanDevices.values()];
@@ -135,7 +152,12 @@ export class ReactNativeBleTransport implements BleTransport {
135152
const onAdvertisement = (error: Error | null, device: Device | null) => {
136153
if (!active || operation !== this.#operation) return;
137154
if (error) { void cancel(new Error('Saved PC discovery failed.')); return; }
138-
if (!device || this.#scanDevices.has(device.id) || this.#scanKeys.has(this.#scanKey(device)) || this.#scanDevices.size >= 4) return;
155+
if (!device || this.#scanDevices.has(device.id) || this.#scanKeys.has(this.#scanKey(device))) return;
156+
if (this.#scanDevices.size >= 4) {
157+
if (waiting.size < 32) waiting.set(device.id, device);
158+
return;
159+
}
160+
waiting.delete(device.id);
139161
this.#scanDevices.set(device.id, device);
140162
this.#scanKeys.add(this.#scanKey(device));
141163
const task = this.#readStatus(device, (desktop) => {
@@ -174,6 +196,8 @@ export class ReactNativeBleTransport implements BleTransport {
174196
if (operation === this.#operation) {
175197
this.#scanDevices.delete(device.id);
176198
this.#scanKeys.delete(this.#scanKey(device));
199+
const next = waiting.values().next().value;
200+
if (next) onAdvertisement(null, next);
177201
}
178202
});
179203
this.#scanTasks.add(task);

0 commit comments

Comments
 (0)