Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions lib/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const {
TypeValidationError,
RangeValidationError,
ValidationError,
TimeoutError,
} = require('./errors.js');
const ParameterService = require('./parameter_service.js');
const ParameterClient = require('./parameter_client.js');
Expand Down Expand Up @@ -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);
Comment on lines +600 to +606
});

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) {
Expand Down
110 changes: 110 additions & 0 deletions test/test-spin-until-future-complete.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
16 changes: 16 additions & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
node: Node,
promise: Promise<T>,
timeoutMs?: number
): Promise<T>;

/**
* Stop all activity, destroy all nodes and node components.
*
Expand Down
17 changes: 17 additions & 0 deletions types/node.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
promise: Promise<T>,
timeoutMs?: number
): Promise<T>;

/**
* Terminate spinning - no further events will be received.
*/
Expand Down
Loading