Skip to content
This repository was archived by the owner on Jul 10, 2025. It is now read-only.

Commit 5e5385c

Browse files
authored
Merge pull request #123 from danditomaso/fix/ble-read-loop-issue
fix: prevent infinite loop in readFromRadio function
2 parents 715e35d + 9ae03a1 commit 5e5385c

File tree

8 files changed

+293
-83
lines changed

8 files changed

+293
-83
lines changed

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
{
22
"name": "@meshtastic/js",
3-
"version": "2.5.9-2",
3+
"version": "2.5.9-3",
44
"description": "Browser library for interfacing with meshtastic devices",
55
"license": "GPL-3.0-only",
66
"scripts": {
77
"build": "tsup && pnpm biome format .",
8+
"lint": "pnpm biome check",
9+
"lint:fix": "pnpm biome check --fix",
10+
"format": "pnpm biome format .",
11+
"format:fix": "pnpm biome format . --fix",
812
"generate:docs": "typedoc src/index.ts"
913
},
1014
"keywords": [

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/adapters/bleConnection.ts

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -213,34 +213,33 @@ export class BleConnection extends MeshDevice {
213213
return await Promise.resolve(true);
214214
}
215215

216-
/** Short description */
216+
/**
217+
* Reads data packets from the radio until empty
218+
* @throws Error if reading fails
219+
*/
217220
protected async readFromRadio(): Promise<void> {
218-
// if (this.pendingRead) {
219-
// return Promise.resolve();
220-
// }
221-
// this.pendingRead = true;
222-
let readBuffer = new ArrayBuffer(1);
223-
224-
while (readBuffer.byteLength > 0 && this.fromRadioCharacteristic) {
225-
await this.fromRadioCharacteristic
226-
.readValue()
227-
.then((value) => {
228-
readBuffer = value.buffer;
229-
230-
if (value.byteLength > 0) {
231-
this.handleFromRadio(new Uint8Array(readBuffer));
232-
}
233-
this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceConnected);
234-
})
235-
.catch((e: Error) => {
236-
readBuffer = new ArrayBuffer(0);
237-
this.log.error(
238-
Types.Emitter[Types.Emitter.ReadFromRadio],
239-
`❌ ${e.message}`,
240-
);
241-
});
221+
try {
222+
let hasMoreData = true;
223+
while (hasMoreData && this.fromRadioCharacteristic) {
224+
const value = await this.fromRadioCharacteristic.readValue();
225+
226+
if (value.byteLength === 0) {
227+
hasMoreData = false;
228+
continue;
229+
}
230+
231+
await this.handleFromRadio(new Uint8Array(value.buffer));
232+
this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceConnected);
233+
}
234+
} catch (error) {
235+
this.log.error(
236+
Types.Emitter[Types.Emitter.ReadFromRadio],
237+
`❌ ${error instanceof Error ? error.message : "Unknown error"}`,
238+
);
239+
throw error; // Re-throw to let caller handle
240+
} finally {
241+
// this.pendingRead = false;
242242
}
243-
// this.pendingRead = false;
244243
}
245244

246245
/**

src/adapters/httpConnection.ts

Lines changed: 190 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ export class HttpConnection extends MeshDevice {
1919

2020
private abortController: AbortController;
2121

22+
private readonly defaultRetryConfig: Types.HttpRetryConfig = {
23+
maxRetries: 3,
24+
initialDelayMs: 1000,
25+
maxDelayMs: 10000,
26+
backoffFactor: 2,
27+
};
28+
2229
constructor(configId?: number) {
2330
super(configId);
2431

@@ -37,6 +44,105 @@ export class HttpConnection extends MeshDevice {
3744
);
3845
}
3946

47+
/**
48+
* Checks if the error should trigger a retry attempt
49+
* @param response - The fetch response
50+
* @returns boolean indicating if should retry
51+
*/
52+
private shouldRetry(response: Response): boolean {
53+
if (response.status >= 500 && response.status <= 599) {
54+
return true;
55+
}
56+
57+
if (!response.ok && response.status < 400) {
58+
return true;
59+
}
60+
61+
return false;
62+
}
63+
64+
/**
65+
* Implements exponential backoff retry logic for HTTP operations
66+
* @param operation - The async operation to retry
67+
* @param retryConfig - Configuration for retry behavior
68+
* @param operationName - Name of the operation for logging
69+
*/
70+
private async withRetry(
71+
operation: () => Promise<Response>,
72+
retryConfig: Types.HttpRetryConfig,
73+
operationName: string,
74+
): Promise<Response> {
75+
let delay = retryConfig.initialDelayMs;
76+
77+
for (let attempt = 1; attempt <= retryConfig.maxRetries; attempt++) {
78+
try {
79+
const response = await operation();
80+
81+
// If the response is success or a non-retryable error, return it
82+
if (!this.shouldRetry(response)) {
83+
return response;
84+
}
85+
86+
const error = new Error(
87+
`HTTP ${response.status}: ${response.statusText}`,
88+
);
89+
90+
if (attempt === retryConfig.maxRetries) {
91+
throw error;
92+
}
93+
94+
this.log.warn(
95+
`${operationName} failed (attempt ${attempt}/${retryConfig.maxRetries}): ${error.message}`,
96+
);
97+
98+
await new Promise((resolve) => setTimeout(resolve, delay));
99+
delay = Math.min(
100+
delay * retryConfig.backoffFactor,
101+
retryConfig.maxDelayMs,
102+
);
103+
} catch (error) {
104+
// If it's not a Response error (e.g., network error), don't retry
105+
if (!(error instanceof Error) || !error.message.startsWith("HTTP")) {
106+
throw error;
107+
}
108+
109+
if (attempt === retryConfig.maxRetries) {
110+
throw error;
111+
}
112+
113+
this.log.warn(
114+
`${operationName} failed (attempt ${attempt}/${retryConfig.maxRetries}): ${error.message}`,
115+
);
116+
}
117+
}
118+
119+
// This line should never be reached due to the error handling above,
120+
throw new Error("Unexpected end of retry loop");
121+
}
122+
123+
/**
124+
* Attempts a single connection to the device
125+
*/
126+
private async attemptConnection(
127+
params: Types.HttpConnectionParameters,
128+
): Promise<Response> {
129+
const { address, tls = false } = params;
130+
this.portId = `${tls ? "https://" : "http://"}${address}`;
131+
132+
// We create a dummy request here just to have a Response object to work with
133+
// The actual connection check is done via ping()
134+
const response = await fetch(`${this.portId}/hotspot-detect.html`, {
135+
signal: this.abortController.signal,
136+
mode: "no-cors",
137+
});
138+
139+
if (!response.ok) {
140+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
141+
}
142+
143+
return response;
144+
}
145+
40146
/**
41147
* Initiates the connect process to a Meshtastic device via HTTP(S)
42148
*/
@@ -46,45 +152,73 @@ export class HttpConnection extends MeshDevice {
46152
receiveBatchRequests = false,
47153
tls = false,
48154
}: Types.HttpConnectionParameters): Promise<void> {
155+
// Set initial state
49156
this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceConnecting);
50-
51157
this.receiveBatchRequests = receiveBatchRequests;
52158

53-
this.portId = `${tls ? "https://" : "http://"}${address}`;
54-
55-
if (
56-
this.deviceStatus === Types.DeviceStatusEnum.DeviceConnecting &&
57-
(await this.ping())
58-
) {
59-
this.log.debug(
60-
Types.Emitter[Types.Emitter.Connect],
61-
"Ping succeeded, starting configuration and request timer.",
159+
try {
160+
// Attempt connection with retries
161+
await this.withRetry(
162+
() => this.attemptConnection({ address, tls, fetchInterval }),
163+
{
164+
...this.defaultRetryConfig,
165+
maxRetries: 5, // More retries for initial connection
166+
maxDelayMs: 10000, // Max 10s between retries
167+
},
168+
"Connect",
62169
);
63-
this.configure().catch(() => {
64-
// TODO: FIX, workaround for `wantConfigId` not getting acks.
65-
});
66-
this.readLoop = setInterval(() => {
67-
this.readFromRadio().catch((e: Error) => {
68-
this.log.error(
170+
171+
// If connection successful, set up device
172+
if (this.deviceStatus === Types.DeviceStatusEnum.DeviceConnecting) {
173+
this.log.debug(
174+
Types.Emitter[Types.Emitter.Connect],
175+
"Connection succeeded, starting configuration and request timer.",
176+
);
177+
178+
// Start device configuration
179+
await this.configure().catch((error) => {
180+
this.log.warn(
69181
Types.Emitter[Types.Emitter.Connect],
70-
`${e.message}`,
182+
`Configuration warning: ${error.message}`,
71183
);
72184
});
73-
}, fetchInterval);
74-
} else if (
75-
this.deviceStatus !== Types.DeviceStatusEnum.DeviceDisconnected
76-
) {
77-
setTimeout(() => {
185+
186+
if (!this.readLoop) {
187+
this.readLoop = setInterval(async () => {
188+
try {
189+
await this.readFromRadio();
190+
} catch (error) {
191+
if (error instanceof Error) {
192+
this.log.error(
193+
Types.Emitter[Types.Emitter.Connect],
194+
`❌ Read loop error: ${error.message}`,
195+
);
196+
}
197+
}
198+
}, fetchInterval);
199+
}
200+
}
201+
} catch (error) {
202+
if (error instanceof Error) {
203+
this.log.error(
204+
Types.Emitter[Types.Emitter.Connect],
205+
`❌ Connection failed: ${error.message}`,
206+
);
207+
}
208+
209+
// Only attempt reconnection if we haven't been disconnected
210+
if (this.deviceStatus !== Types.DeviceStatusEnum.DeviceDisconnected) {
211+
this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceReconnecting);
212+
78213
this.connect({
79-
address: address,
80-
fetchInterval: fetchInterval,
81-
receiveBatchRequests: receiveBatchRequests,
82-
tls: tls,
214+
address,
215+
fetchInterval,
216+
receiveBatchRequests,
217+
tls,
83218
});
84-
}, 10000);
219+
}
85220
}
86221
}
87-
88222
/** Disconnects from the Meshtastic device */
89223
public disconnect(): void {
90224
this.abortController.abort();
@@ -95,7 +229,7 @@ export class HttpConnection extends MeshDevice {
95229
}
96230
}
97231

98-
/** Pings device to check if it is avaliable */
232+
/** Pings device to check if it is available with retry logic */
99233
public async ping(): Promise<boolean> {
100234
this.log.debug(
101235
Types.Emitter[Types.Emitter.Ping],
@@ -104,22 +238,34 @@ export class HttpConnection extends MeshDevice {
104238

105239
const { signal } = this.abortController;
106240

107-
let pingSuccessful = false;
241+
try {
242+
const response = await this.withRetry(
243+
async () => {
244+
return await fetch(`${this.portId}/hotspot-detect.html`, {
245+
signal,
246+
mode: "no-cors",
247+
});
248+
},
249+
{ ...this.defaultRetryConfig },
250+
"Ping",
251+
);
108252

109-
await fetch(`${this.portId}/hotspot-detect.html`, {
110-
signal,
111-
mode: "no-cors",
112-
})
113-
.then(() => {
114-
pingSuccessful = true;
115-
this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceConnected);
116-
})
117-
.catch((e: Error) => {
118-
pingSuccessful = false;
119-
this.log.error(Types.Emitter[Types.Emitter.Ping], `❌ ${e.message}`);
120-
this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceReconnecting);
121-
});
122-
return pingSuccessful;
253+
if (!response.ok) {
254+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
255+
}
256+
257+
this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceConnected);
258+
return true;
259+
} catch (error) {
260+
if (error instanceof Error) {
261+
this.log.error(
262+
Types.Emitter[Types.Emitter.Ping],
263+
`❌ ${error.message}`,
264+
);
265+
}
266+
this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceReconnecting);
267+
return false;
268+
}
123269
}
124270

125271
/** Reads any avaliable protobuf messages from the radio */

0 commit comments

Comments
 (0)