diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 87b494d6..36089ae2 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -1,40 +1,33 @@ # rclnodejs contributors (sorted alphabetically) - **[Alaa El Jawad](https://github.com/ejalaa12)** - - Fix compatibility with ROS2 parameters array types - Unit tests for all parameter types - Handle concurrent ROS2 client calls, with unit tests - **[Alex Mikhalev](https://github.com/amikhalev)** - - Fix build for AMENT_PREFIX_PATH with multiple entries - **[Felix Divo](https://github.com/felixdivo)** - - Code cleanup of index.js, tests cases & message generation - Improved shutdown behavior - Fixed compilation warnings - **[Hanyia](https://github.com/hanyia)** - - Benchmark test script - **[Ian McElroy](https://github.com/imcelroy)** - - Add descriptor namespace for all interfaces, rostsd_gen improvements - Fix compatibility with ROS2 parameters array types - Unit tests for all parameter types - Handle concurrent ROS2 client calls, with unit tests - **[Kenny Yuan](https://github.com/kenny-y)** - - Message features: JS generation, typed arrays, plain JS object, compound msgs, many others... - npm publish scripts - Mac support - **[Matt Richard](https://github.com/mattrichard)** - - ROS2 Actions - Guard conditions - Node utility methods (countPublishers/subscribers...) @@ -42,21 +35,21 @@ - Node 12 compatibility - **[Minggang Wang](https://github.com/minggangw)** - - Author, lead developer, maintainer - Core, CI -- **[Martins Mozeiko](https://github.com/martins-mozeiko)** +- **[Mahmoud Alghalayini](https://github.com/mahmoud-ghalayini)** + - JSON safe serialization improvements + - Promise-based service calls implementation +- **[Martins Mozeiko](https://github.com/martins-mozeiko)** - QoS new/delete fix - **[Qiuzhong](https://github.com/qiuzhong)** - - Test coverage for actions, topics, multi-array messages, cross platform, security - Converted from setTimeout to ROS2 Timer - **[Teo Koon Peng](https://github.com/koonpeng)** - - TypeScript improvements - Added Client#waitForService - Code style improvements, e.g., Prettier formatting diff --git a/example/services/README.md b/example/services/README.md index 72ae0039..52f337bf 100644 --- a/example/services/README.md +++ b/example/services/README.md @@ -48,6 +48,63 @@ ROS 2 services provide a request-response communication pattern where clients se - Asynchronous request handling with callbacks - **Run Command**: `node example/services/client/client-example.js` +#### Async Service Client (`client/async-client-example.js`) + +**Purpose**: Demonstrates modern async/await patterns for service communication, solving callback hell and providing cleaner error handling. + +- **Service Type**: `example_interfaces/srv/AddTwoInts` +- **Service Name**: `add_two_ints` +- **Functionality**: + - Multiple examples showing different async patterns + - Simple async/await calls without callbacks + - Timeout handling with configurable timeouts + - Request cancellation using AbortController + - Sequential and parallel service calls + - Comprehensive error handling +- **Features**: + - **Modern JavaScript**: Clean async/await syntax instead of callback hell + - **Timeout Support**: Built-in timeout with `options.timeout` (uses `AbortSignal.timeout()` internally) + - **Cancellation**: Request cancellation using `AbortController` and `options.signal` + - **Error Types**: Specific error types (`TimeoutError`, `AbortError`) for better error handling (async only) + - **Backward Compatible**: Works alongside existing callback-based `sendRequest()` + - **TypeScript Ready**: Full type safety with comprehensive TypeScript definitions +- **Run Command**: `node example/services/client/async-client-example.js` + +**Key API Differences**: + +```javascript +client.sendRequest(request, (response) => { + console.log('Response:', response.sum); +}); + +try { + const response = await client.sendRequestAsync(request); + + const response = await client.sendRequestAsync(request, { timeout: 5000 }); + + const controller = new AbortController(); + const response = await client.sendRequestAsync(request, { + signal: controller.signal, + }); + + const controller = new AbortController(); + const response = await client.sendRequestAsync(request, { + timeout: 5000, + signal: controller.signal, + }); + + console.log('Response:', response.sum); +} catch (error) { + if (error.name === 'TimeoutError') { + console.log('Request timed out'); + } else if (error.name === 'AbortError') { + console.log('Request was cancelled'); + } else { + console.error('Service error:', error.message); + } +} +``` + ### GetMap Service #### Service Server (`service/getmap-service-example.js`) @@ -129,6 +186,41 @@ ROS 2 services provide a request-response communication pattern where clients se Result: object { sum: 79n } ``` +### Running the Async AddTwoInts Client Example + +1. **Prerequisites**: Ensure ROS 2 is installed and sourced + +2. **Start the Service Server**: Use the same service server as above: + + ```bash + cd /path/to/rclnodejs + node example/services/service/service-example.js + ``` + +3. **Start the Async Client**: In another terminal, run: + + ```bash + cd /path/to/rclnodejs + node example/services/client/async-client-example.js + ``` + +4. **Expected Output**: + + **Service Server Terminal**: (Same as regular client) + + ``` + Incoming request: object { a: 42n, b: 37n } + Sending response: object { sum: 79n } + -- + ``` + + **Async Client Terminal**: + + ``` + Sending: object { a: 42n, b: 37n } + Result: object { sum: 79n } + ``` + ### Running the GetMap Service Example 1. **Prerequisites**: Ensure ROS 2 is installed and sourced @@ -236,8 +328,11 @@ This script automatically starts the service, tests the client, and cleans up. ### Programming Patterns -- **Async/Await**: Modern JavaScript patterns for asynchronous operations -- **Callback Handling**: Response processing using callback functions +- **Modern Async/Await**: Clean Promise-based service calls with `sendRequestAsync()` +- **Traditional Callbacks**: Response processing using callback functions with `sendRequest()` +- **Error Handling**: Proper error handling with try/catch blocks and specific error types (async only) +- **Timeout Management**: Built-in timeout support to prevent hanging requests (async only) +- **Request Cancellation**: AbortController support for user-cancellable operations (async only) - **Resource Management**: Proper node shutdown and cleanup - **Data Analysis**: Processing and interpreting received data - **Visualization**: Converting data to human-readable formats @@ -331,18 +426,23 @@ int8[] data ### Common Issues 1. **Service Not Available**: - - Ensure the service server is running before starting the client - Check that both use the same service name (`add_two_ints`) 2. **Type Errors**: - - Ensure you're using `BigInt()` for integer values, not regular numbers - Use `response.template` to get the correct response structure 3. **Client Hangs**: - The client waits for service availability with a 1-second timeout - If the service isn't available, the client will log an error and shut down + - For async clients, use timeout options: `client.sendRequestAsync(request, { timeout: 5000 })` + +4. **Async/Await Issues** (applies only to `sendRequestAsync()`): + - **Unhandled Promise Rejections**: Always use try/catch blocks around `sendRequestAsync()` + - **Timeout Errors**: Handle `TimeoutError` specifically for timeout scenarios (async only) + - **Cancelled Requests**: Handle `AbortError` when using AbortController cancellation (async only) + - **Mixed Patterns**: You can use both `sendRequest()` and `sendRequestAsync()` in the same code ### Debugging Tips @@ -354,6 +454,10 @@ int8[] data - Both examples use the standard rclnodejs initialization pattern - The service server runs continuously until manually terminated -- The client performs a single request-response cycle then exits +- The traditional client performs a single request-response cycle then exits +- The async client demonstrates multiple patterns and then exits +- **New async/await support**: Use `sendRequestAsync()` for modern Promise-based patterns +- **Full backward compatibility**: Existing `sendRequest()` callback-based code continues to work unchanged +- **TypeScript support**: Full type safety available for async methods - Service introspection is only available in ROS 2 Iron and later distributions - BigInt is required for integer message fields to maintain precision diff --git a/example/services/client/async-client-example.js b/example/services/client/async-client-example.js new file mode 100644 index 00000000..6ded71e2 --- /dev/null +++ b/example/services/client/async-client-example.js @@ -0,0 +1,66 @@ +// Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +const rclnodejs = require('../../../index.js'); + +async function main() { + await rclnodejs.init(); + const node = rclnodejs.createNode('async_client_example_node'); + const client = node.createClient( + 'example_interfaces/srv/AddTwoInts', + 'add_two_ints' + ); + + if ( + rclnodejs.DistroUtils.getDistroId() > + rclnodejs.DistroUtils.getDistroId('humble') + ) { + // To view service events use the following command: + // ros2 topic echo "/add_two_ints/_service_event" + client.configureIntrospection( + node.getClock(), + rclnodejs.QoS.profileSystemDefault, + rclnodejs.ServiceIntrospectionStates.METADATA + ); + } + + const request = { + a: BigInt(Math.floor(Math.random() * 100)), + b: BigInt(Math.floor(Math.random() * 100)), + }; + + let result = await client.waitForService(1000); + if (!result) { + console.log('Error: service not available'); + rclnodejs.shutdown(); + return; + } + + rclnodejs.spin(node); + + console.log(`Sending: ${typeof request}`, request); + + try { + const response = await client.sendRequestAsync(request, { timeout: 5000 }); + console.log(`Result: ${typeof response}`, response); + } catch (error) { + console.log(`Error: ${error.message}`); + } finally { + rclnodejs.shutdown(); + } +} + +main(); diff --git a/lib/client.js b/lib/client.js index fd7f0f27..190dd2a8 100644 --- a/lib/client.js +++ b/lib/client.js @@ -33,7 +33,7 @@ class Client extends Entity { } /** - * This callback is called when a resopnse is sent back from service + * This callback is called when a response is sent back from service * @callback ResponseCallback * @param {Object} response - The response sent from the service * @see [Client.sendRequest]{@link Client#sendRequest} @@ -43,7 +43,7 @@ class Client extends Entity { */ /** - * Send the request and will be notified asynchronously if receiving the repsonse. + * Send the request and will be notified asynchronously if receiving the response. * @param {object} request - The request to be submitted. * @param {ResponseCallback} callback - Thc callback function for receiving the server response. * @return {undefined} @@ -65,6 +65,102 @@ class Client extends Entity { this._sequenceNumberToCallbackMap.set(sequenceNumber, callback); } + /** + * Send the request and return a Promise that resolves with the response. + * @param {object} request - The request to be submitted. + * @param {object} [options] - Optional parameters for the request. + * @param {number} [options.timeout] - Timeout in milliseconds for the request. + * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request. + * @return {Promise} Promise that resolves with the service response. + * @throws {TimeoutError} If the request times out (when options.timeout is exceeded). + * @throws {AbortError} If the request is manually aborted (via options.signal). + * @throws {Error} If the request fails for other reasons. + */ + sendRequestAsync(request, options = {}) { + return new Promise((resolve, reject) => { + let sequenceNumber = null; + let isResolved = false; + let isTimeout = false; + + const cleanup = () => { + if (sequenceNumber !== null) { + this._sequenceNumberToCallbackMap.delete(sequenceNumber); + } + isResolved = true; + }; + + let effectiveSignal = options.signal; + + if (options.timeout !== undefined && options.timeout >= 0) { + const timeoutSignal = AbortSignal.timeout(options.timeout); + + timeoutSignal.addEventListener('abort', () => { + isTimeout = true; + }); + + if (options.signal) { + effectiveSignal = AbortSignal.any([options.signal, timeoutSignal]); + } else { + effectiveSignal = timeoutSignal; + } + } + + if (effectiveSignal) { + if (effectiveSignal.aborted) { + const error = new Error( + isTimeout + ? `Request timeout after ${options.timeout}ms` + : 'Request was aborted' + ); + error.name = isTimeout ? 'TimeoutError' : 'AbortError'; + if (isTimeout) { + error.code = 'TIMEOUT'; + } + reject(error); + return; + } + + effectiveSignal.addEventListener('abort', () => { + if (!isResolved) { + cleanup(); + const error = new Error( + isTimeout + ? `Request timeout after ${options.timeout}ms` + : 'Request was aborted' + ); + error.name = isTimeout ? 'TimeoutError' : 'AbortError'; + if (isTimeout) { + error.code = 'TIMEOUT'; + } + reject(error); + } + }); + } + + try { + let requestToSend = + request instanceof this._typeClass.Request + ? request + : new this._typeClass.Request(request); + + let rawRequest = requestToSend.serialize(); + sequenceNumber = rclnodejs.sendRequest(this._handle, rawRequest); + + debug(`Client has sent a ${this._serviceName} request (async).`); + + this._sequenceNumberToCallbackMap.set(sequenceNumber, (response) => { + if (!isResolved) { + cleanup(); + resolve(response); + } + }); + } catch (error) { + cleanup(); + reject(error); + } + }); + } + processResponse(sequenceNumber, response) { if (this._sequenceNumberToCallbackMap.has(sequenceNumber)) { debug(`Client has received ${this._serviceName} response from service.`); diff --git a/test/test-async-client.js b/test/test-async-client.js new file mode 100644 index 00000000..b5c49ec1 --- /dev/null +++ b/test/test-async-client.js @@ -0,0 +1,294 @@ +// Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +const assert = require('assert'); +const rclnodejs = require('../index.js'); + +describe('Client async functionality', function () { + this.timeout(60 * 1000); + + let node; + let client; + let service; + let pendingTimeouts = []; + + before(async function () { + rclnodejs.init(); + node = rclnodejs.createNode('test_async_client'); + + service = node.createService( + 'example_interfaces/srv/AddTwoInts', + 'test_add_service', + (request, response) => { + if ( + (request.a === 1n && request.b === 1n) || + (request.a === 0n && request.b === 0n) + ) { + const timeoutId = setTimeout(() => { + try { + const result = response.template; + result.sum = request.a + request.b; + response.send(result); + } catch (error) { + // Service may have been destroyed + } + }, 100); + pendingTimeouts.push(timeoutId); + } else if (request.a === 50n && request.b === 60n) { + const timeoutId = setTimeout(() => { + try { + const result = response.template; + result.sum = request.a + request.b; + response.send(result); + } catch (error) { + // Service may have been destroyed + } + }, 50); + pendingTimeouts.push(timeoutId); + } else { + const result = response.template; + result.sum = request.a + request.b; + response.send(result); + } + } + ); + + client = node.createClient( + 'example_interfaces/srv/AddTwoInts', + 'test_add_service' + ); + + rclnodejs.spin(node); + + const available = await client.waitForService(5000); + assert.ok(available, 'Service should be available'); + }); + + after(function () { + pendingTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); + pendingTimeouts = []; + rclnodejs.shutdown(); + }); + + describe('sendRequestAsync', function () { + it('should exist as a method', function () { + assert.strictEqual(typeof client.sendRequestAsync, 'function'); + }); + + it('should return a Promise', function () { + const request = { a: BigInt(1), b: BigInt(2) }; + const result = client.sendRequestAsync(request); + assert.ok(result instanceof Promise); + + result.catch(() => {}); + }); + + it('should resolve with correct response', async function () { + const request = { a: BigInt(10), b: BigInt(20) }; + const response = await client.sendRequestAsync(request); + + assert.strictEqual(typeof response, 'object'); + assert.strictEqual(response.sum, BigInt(30)); + }); + + it('should work without options', async function () { + const request = { a: BigInt(5), b: BigInt(7) }; + const response = await client.sendRequestAsync(request); + + assert.strictEqual(response.sum, BigInt(12)); + }); + + it('should work with empty options', async function () { + const request = { a: BigInt(3), b: BigInt(4) }; + const response = await client.sendRequestAsync(request, {}); + + assert.strictEqual(response.sum, BigInt(7)); + }); + + it('should handle timeout option', async function () { + const request = { a: BigInt(15), b: BigInt(25) }; + + const response = await client.sendRequestAsync(request, { + timeout: 5000, + }); + assert.strictEqual(response.sum, BigInt(40)); + }); + + it('should timeout with very short timeout', async function () { + const request = { a: BigInt(1), b: BigInt(1) }; + + try { + await client.sendRequestAsync(request, { timeout: 1 }); + assert.fail('Should have timed out'); + } catch (error) { + assert.strictEqual(error.name, 'TimeoutError'); + assert.ok(error.message.includes('timeout')); + assert.strictEqual(error.code, 'TIMEOUT'); + } + }); + + it('should handle AbortController cancellation', async function () { + const controller = new AbortController(); + const request = { a: BigInt(100), b: BigInt(200) }; + + controller.abort(); + + try { + await client.sendRequestAsync(request, { signal: controller.signal }); + assert.fail('Should have been aborted'); + } catch (error) { + assert.strictEqual(error.name, 'AbortError'); + assert.ok(error.message.includes('aborted')); + } + }); + + it('should handle AbortController cancellation during request', async function () { + const controller = new AbortController(); + const request = { a: BigInt(50), b: BigInt(60) }; + + setTimeout(() => controller.abort(), 10); + + try { + await client.sendRequestAsync(request, { + signal: controller.signal, + timeout: 5000, + }); + assert.fail('Should have been aborted'); + } catch (error) { + assert.strictEqual(error.name, 'AbortError'); + } + }); + + it('should handle multiple concurrent requests', async function () { + const requests = [ + { a: BigInt(1), b: BigInt(1) }, + { a: BigInt(2), b: BigInt(2) }, + { a: BigInt(3), b: BigInt(3) }, + ]; + + const promises = requests.map((request) => + client.sendRequestAsync(request, { timeout: 5000 }) + ); + + const responses = await Promise.all(promises); + + assert.strictEqual(responses.length, 3); + assert.strictEqual(responses[0].sum, BigInt(2)); + assert.strictEqual(responses[1].sum, BigInt(4)); + assert.strictEqual(responses[2].sum, BigInt(6)); + }); + + it('should clean up properly on success', async function () { + const initialMapSize = client._sequenceNumberToCallbackMap.size; + + const request = { a: BigInt(8), b: BigInt(9) }; + await client.sendRequestAsync(request); + + assert.strictEqual( + client._sequenceNumberToCallbackMap.size, + initialMapSize + ); + }); + + it('should clean up properly on error', async function () { + const initialMapSize = client._sequenceNumberToCallbackMap.size; + + const request = { a: BigInt(1), b: BigInt(1) }; + + try { + await client.sendRequestAsync(request, { timeout: 1 }); + } catch (error) {} + + assert.strictEqual( + client._sequenceNumberToCallbackMap.size, + initialMapSize + ); + }); + + it('should not interfere with existing sendRequest method', function (done) { + const request = { a: BigInt(6), b: BigInt(7) }; + + client.sendRequest(request, (response) => { + assert.strictEqual(response.sum, BigInt(13)); + done(); + }); + }); + + it('should work alongside callback method', async function () { + const request1 = { a: BigInt(10), b: BigInt(11) }; + const request2 = { a: BigInt(12), b: BigInt(13) }; + + const asyncPromise = client.sendRequestAsync(request1); + + const callbackPromise = new Promise((resolve) => { + client.sendRequest(request2, (response) => { + resolve(response); + }); + }); + + const [asyncResponse, callbackResponse] = await Promise.all([ + asyncPromise, + callbackPromise, + ]); + + assert.strictEqual(asyncResponse.sum, BigInt(21)); + assert.strictEqual(callbackResponse.sum, BigInt(25)); + }); + }); + + describe('Error handling edge cases', function () { + it('should handle invalid request objects gracefully', async function () { + try { + await client.sendRequestAsync(null); + assert.fail('Should have thrown an error'); + } catch (error) { + assert.ok(error instanceof Error); + } + }); + + it('should handle zero and negative timeouts', async function () { + const request = { a: BigInt(1), b: BigInt(1) }; + + try { + await client.sendRequestAsync(request, { timeout: 0 }); + assert.fail('Should have timed out immediately'); + } catch (error) { + assert.strictEqual(error.name, 'TimeoutError'); + } + }); + + it('should ignore negative timeout values', async function () { + const request = { a: BigInt(2), b: BigInt(3) }; + + const response = await client.sendRequestAsync(request, { timeout: -1 }); + assert.strictEqual(response.sum, BigInt(5)); + }); + + it('should handle already aborted signal', async function () { + const controller = new AbortController(); + controller.abort(); + + const request = { a: BigInt(1), b: BigInt(1) }; + + try { + await client.sendRequestAsync(request, { signal: controller.signal }); + assert.fail('Should have been aborted immediately'); + } catch (error) { + assert.ok(error.message.includes('aborted')); + } + }); + }); +}); diff --git a/types/client.d.ts b/types/client.d.ts index f884be2b..12828fca 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -14,6 +14,21 @@ declare module 'rclnodejs' { callback: Client.ResponseCallback ): void; + /** + * Make a service request and return a Promise that resolves with the response. + * + * @param request - Request to be submitted. + * @param options - Optional parameters for the request. + * @returns Promise that resolves with the service response. + * @throws TimeoutError if the request times out (when options.timeout is exceeded). + * @throws AbortError if the request is manually aborted (via options.signal). + * @throws Error if the request fails for other reasons. + */ + sendRequestAsync( + request: ServiceRequestMessage, + options?: Client.AsyncRequestOptions + ): Promise>; + /** * Checks if the service is ready. * @@ -74,5 +89,26 @@ declare module 'rclnodejs' { export type ResponseCallback> = ( response: ServiceResponseMessage ) => void; + + /** + * Options for async service requests + */ + export interface AsyncRequestOptions { + /** + * Timeout in milliseconds for the request. + * Internally uses AbortSignal.timeout() for standards compliance. + */ + timeout?: number; + + /** + * AbortSignal to cancel the request. + * When the signal is aborted, the request will be cancelled + * and the promise will reject with an AbortError. + * + * Can be combined with timeout parameter - whichever happens first + * will abort the request. + */ + signal?: AbortSignal; + } } }