diff --git a/index.js b/index.js index d347c68a..4f402bc4 100644 --- a/index.js +++ b/index.js @@ -458,6 +458,30 @@ let rcl = { node.spinOnce(timeout); }, + /** + * Spin the node until a Promise resolves, rejects, or a timeout expires. + * + * This is the rclnodejs equivalent of rclpy's `spin_until_future_complete`. + * Starts spinning the node (if not already), waits for the promise to settle, + * and stops spinning when done (if it started it). + * + * @param {Node} node - The node to spin. + * @param {Promise} promise - The Promise to wait for. + * @param {number} [timeoutMs] - Optional timeout in milliseconds. + * @returns {Promise<*>} - Resolves with the value of the input promise. + * @throws {Error} If the promise rejects or the timeout expires. + * + * @example + * const response = await rclnodejs.spinUntilFutureComplete( + * node, + * client.sendRequest(request), + * 5000 + * ); + */ + spinUntilFutureComplete(node, promise, timeoutMs) { + return node.spinUntilFutureComplete(promise, timeoutMs); + }, + /** * Shutdown an RCL environment identified by a context. The shutdown process will * destroy all nodes and related resources in the context. If no context is diff --git a/lib/node.js b/lib/node.js index 36ad6914..c7040765 100644 --- a/lib/node.js +++ b/lib/node.js @@ -36,6 +36,7 @@ const { TypeValidationError, RangeValidationError, ValidationError, + TimeoutError, } = require('./errors.js'); const ParameterService = require('./parameter_service.js'); const ParameterClient = require('./parameter_client.js'); @@ -536,6 +537,90 @@ class Node extends rclnodejs.ShadowNode { super.spinOnce(this.context.handle, timeout); } + /** + * Spin the node until a Promise resolves, rejects, or a timeout expires. + * + * This is the rclnodejs equivalent of rclpy's `spin_until_future_complete`. + * It starts spinning (if not already spinning), waits for the promise to + * settle, and then stops spinning (if it started it). + * + * @param {Promise} promise - The Promise to wait for. + * @param {number} [timeoutMs] - Optional timeout in milliseconds. + * If provided and the promise does not settle within the timeout, + * a TimeoutError is thrown. If omitted, waits indefinitely. + * @returns {Promise<*>} - Resolves with the value of the input promise. + * @throws {Error} If the promise rejects or the timeout expires. + * + * @example + * // Wait for a service response with a 5-second timeout + * const response = await node.spinUntilFutureComplete( + * client.sendRequest(request), + * 5000 + * ); + * + * @example + * // Wait indefinitely + * const response = await node.spinUntilFutureComplete( + * client.sendRequest(request) + * ); + */ + async spinUntilFutureComplete(promise, timeoutMs) { + if (!(promise && typeof promise.then === 'function')) { + throw new TypeValidationError('promise', promise, 'Promise (thenable)', { + nodeName: this.name(), + }); + } + + if (timeoutMs != null) { + if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs)) { + throw new TypeValidationError( + 'timeoutMs', + timeoutMs, + 'finite number (milliseconds)', + { nodeName: this.name() } + ); + } + if (timeoutMs < 0) { + throw new RangeValidationError('timeoutMs', timeoutMs, '>= 0', { + nodeName: this.name(), + }); + } + } + + const wasSpinning = this.spinning; + if (!wasSpinning) { + this.spin(); + } + + try { + if (timeoutMs != null) { + let timer; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new TimeoutError( + `spinUntilFutureComplete timed out after ${timeoutMs}ms`, + { nodeName: this.name() } + ) + ); + }, timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timer); + } + } + + return await promise; + } finally { + if (!wasSpinning) { + this.stop(); + } + } + } + _removeEntityFromArray(entity, array) { let index = array.indexOf(entity); if (index > -1) { diff --git a/test/test-spin-until-future-complete.js b/test/test-spin-until-future-complete.js new file mode 100644 index 00000000..9985f241 --- /dev/null +++ b/test/test-spin-until-future-complete.js @@ -0,0 +1,110 @@ +// Copyright (c) 2026, The Robot Web Tools Contributors +// +// 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('spinUntilFutureComplete tests', function () { + this.timeout(60 * 1000); + + let node; + + before(function () { + return rclnodejs.init(); + }); + + after(function () { + rclnodejs.shutdown(); + }); + + beforeEach(function () { + node = rclnodejs.createNode('spin_future_test_node'); + }); + + afterEach(function () { + if (node.spinning) { + node.stop(); + } + node.destroy(); + }); + + it('should resolve when promise resolves', async function () { + const result = await node.spinUntilFutureComplete(Promise.resolve(42)); + assert.strictEqual(result, 42); + assert.strictEqual(node.spinning, false); + }); + + it('should reject when promise rejects', async function () { + await assert.rejects( + () => + node.spinUntilFutureComplete(Promise.reject(new Error('test error'))), + { message: 'test error' } + ); + assert.strictEqual(node.spinning, false); + }); + + it('should timeout when promise does not resolve', async function () { + const neverResolves = new Promise(() => {}); + await assert.rejects( + () => node.spinUntilFutureComplete(neverResolves, 500), + (error) => { + assert.strictEqual(error.name, 'TimeoutError'); + return true; + } + ); + assert.strictEqual(node.spinning, false); + }); + + it('should not stop spinning if node was already spinning', async function () { + node.spin(); + assert.strictEqual(node.spinning, true); + + const result = await node.spinUntilFutureComplete(Promise.resolve('hello')); + assert.strictEqual(result, 'hello'); + assert.strictEqual(node.spinning, true); // still spinning + }); + + it('should stop spinning after completion if it started spinning', async function () { + assert.strictEqual(node.spinning, false); + + const result = await node.spinUntilFutureComplete( + new Promise((resolve) => setTimeout(() => resolve('delayed'), 100)) + ); + assert.strictEqual(result, 'delayed'); + assert.strictEqual(node.spinning, false); + }); + + it('should work with delayed promises', async function () { + const result = await node.spinUntilFutureComplete( + new Promise((resolve) => setTimeout(() => resolve('ok'), 200)), + 5000 + ); + assert.strictEqual(result, 'ok'); + }); + + it('should throw for invalid promise argument', async function () { + await assert.rejects(() => node.spinUntilFutureComplete('not a promise')); + await assert.rejects(() => node.spinUntilFutureComplete(null)); + }); + + it('should work via module-level function', async function () { + const result = await rclnodejs.spinUntilFutureComplete( + node, + Promise.resolve('module-level') + ); + assert.strictEqual(result, 'module-level'); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 653e3ad0..4d04456e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -85,6 +85,22 @@ declare module 'rclnodejs' { * @deprecated since 0.18.0, Use Node.spinOnce(timeout)*/ function spinOnce(node: Node, timeout?: number): void; + /** + * Spin the node until a Promise resolves, rejects, or a timeout expires. + * + * This is the rclnodejs equivalent of rclpy's `spin_until_future_complete`. + * + * @param node - The node to spin. + * @param promise - The Promise to wait for. + * @param timeoutMs - Optional timeout in milliseconds. + * @returns Resolves with the value of the input promise. + */ + function spinUntilFutureComplete( + node: Node, + promise: Promise, + timeoutMs?: number + ): Promise; + /** * Stop all activity, destroy all nodes and node components. * diff --git a/types/node.d.ts b/types/node.d.ts index 47676b3f..cb0492dc 100644 --- a/types/node.d.ts +++ b/types/node.d.ts @@ -285,6 +285,23 @@ declare module 'rclnodejs' { */ spinOnce(timeout?: number): void; + /** + * Spin the node until a Promise resolves, rejects, or a timeout expires. + * + * This is the rclnodejs equivalent of rclpy's `spin_until_future_complete`. + * Starts spinning the node (if not already), waits for the promise to settle, + * and stops spinning when done (if it started it). + * + * @param promise - The Promise to wait for. + * @param timeoutMs - Optional timeout in milliseconds. + * @returns Resolves with the value of the input promise. + * @throws Error if the promise rejects or the timeout expires. + */ + spinUntilFutureComplete( + promise: Promise, + timeoutMs?: number + ): Promise; + /** * Terminate spinning - no further events will be received. */