Skip to content

Commit 80b0653

Browse files
committed
Fix: resolve logic bugs, resource leaks, and error handling issues in JavaScript lib/ (#1410)
Comprehensive review and fix of all JavaScript files under `lib/` that implement the rclnodejs client library. Eight issues were identified and resolved. ### 1. Incorrect `instanceof` operator precedence (`lib/node.js`) Changed `(!parameter) instanceof Parameter` to `!(parameter instanceof Parameter)`. The original expression evaluated `!parameter` to a boolean first, which is never an instance of `Parameter` — the validation was always `false`, silently accepting invalid objects as parameter overrides. ### 2. Broken chained comparison in `validType()` (`lib/parameter.js`) Changed `PARAMETER_NOT_SET <= parameterType <= PARAMETER_STRING_ARRAY` to two separate comparisons joined with `&&`. JavaScript evaluates chained comparisons as `(a <= b) <= c`, where `(a <= b)` produces a boolean (0 or 1) — the result was always `true`, allowing any numeric value to pass type validation. ### 3. Missing `else if` in `_validArray()` (`lib/parameter.js`) Changed three standalone `if` blocks to `else if` in the array element type resolution. The conditions are mutually exclusive by design; without `else if`, later branches could overwrite a correctly resolved `arrayElementType`. ### 4. Typo `_loggger` → `_logger` (`lib/lifecycle_publisher.js`) Fixed triple-g typo in both the property declaration and usage site. The typo was consistent so it didn't crash at runtime, but any code referencing the standard `_logger` spelling would get `undefined`. ### 5. Duplicate getter/setter with `for...in` bug and missing unsubscribe (`lib/time_source.js`) Removed the first duplicate `get/set isRosTimeActive` definition. It contained two bugs: `for (const clock in ...)` iterated over array indices (strings), not clock objects, making the property assignment a no-op; and the `else` branch incorrectly set `this._node._clockSubscription` instead of `this._clockSubscription`. Additionally, the retained setter was missing cleanup logic when ROS time is deactivated — it subscribed to the clock topic when enabled but never unsubscribed when disabled. Added an `else` branch to destroy the clock subscription (matching the pattern in `detachNode()`), preventing an orphaned subscription from continuing to invoke `_clockCallback` unnecessarily. ### 6. Unhandled promise rejection in service callback (`lib/service.js`) Added `.catch()` to the `Promise.resolve(this._callback(...)).then(...)` chain. Without it, if the user's service callback threw or rejected, the rejection was unhandled — potentially crashing the process or leaving clients hanging. The error handler uses `Logging.getLogger('rclnodejs').error()` instead of `debug()` so that service callback errors are visible through the ROS logging system by default, without requiring the `DEBUG=rclnodejs:service` environment variable. ### 7. TypeError on unknown distro ID (`lib/distro.js`) Added null check before accessing `[0]` on the `Array.find()` result. Previously, calling `getDistroName()` with an ID not in the map would throw `TypeError: Cannot read properties of undefined`. It now returns `undefined`. ### 8. Feedback callback leak for rejected goals (`lib/action/client.js`) Added cleanup of the `_feedbackCallbacks` map entry when a goal is rejected in `processGoalResponse()`. Previously, the callback registered in `sendGoal()` was never removed for rejected goals, causing unbounded map growth. ### Tests added - `test/test-parameters.js` — 4 new tests: - Rejects non-Parameter objects in parameterOverrides (validates issue 1) - Rejects invalid numeric parameter types (validates issue 2) - Accepts all known ParameterTypes - Validates array parameter types (validates issue 3) - `test/test-distro.js` — 1 new test: - `getDistroName(99999)` returns `undefined` instead of throwing (validates issue 7) Fix: #1409
1 parent 9339d69 commit 80b0653

10 files changed

Lines changed: 117 additions & 40 deletions

File tree

lib/action/client.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ class ActionClient extends Entity {
126126
}
127127

128128
this._goalHandles.set(uuid, goalHandle);
129+
} else {
130+
// Clean up feedback callback for rejected goals
131+
let uuid = ActionUuid.fromMessage(
132+
this._sequenceNumberGoalIdMap.get(sequence)
133+
).toString();
134+
this._feedbackCallbacks.delete(uuid);
129135
}
130136

131137
this._pendingGoalRequests.get(sequence).setResult(goalHandle);

lib/distro.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ const DistroUtils = {
6565
return process.env.ROS_DISTRO;
6666
}
6767

68-
return [...DistroNameIdMap].find(([, val]) => val == distroId)[0];
68+
const result = [...DistroNameIdMap].find(([, val]) => val == distroId);
69+
return result ? result[0] : undefined;
6970
},
7071

7172
getKnownDistroNames: function () {

lib/lifecycle_publisher.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class LifecyclePublisher extends Publisher {
3030
super(handle, typeClass, /*topic=*/ '', options);
3131

3232
this._enabled = false;
33-
this._loggger = Logging.getLogger('LifecyclePublisher');
33+
this._logger = Logging.getLogger('LifecyclePublisher');
3434
}
3535

3636
/**
@@ -42,7 +42,7 @@ class LifecyclePublisher extends Publisher {
4242
*/
4343
publish(message) {
4444
if (!this._enabled) {
45-
this._loggger.warn(
45+
this._logger.warn(
4646
`Trying to publish message on the topic ${this.topic}, but the publisher is not activated`
4747
);
4848

lib/node.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ class Node extends rclnodejs.ShadowNode {
151151
// override cli parameterOverrides with those specified in options
152152
if (options.parameterOverrides.length > 0) {
153153
for (const parameter of options.parameterOverrides) {
154-
if ((!parameter) instanceof Parameter) {
154+
if (!(parameter instanceof Parameter)) {
155155
throw new TypeValidationError(
156156
'parameterOverride',
157157
parameter,

lib/parameter.js

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -864,9 +864,8 @@ function parameterTypeFromValue(value) {
864864
function validType(parameterType) {
865865
let result =
866866
typeof parameterType === 'number' &&
867-
ParameterType.PARAMETER_NOT_SET <=
868-
parameterType <=
869-
ParameterType.PARAMETER_STRING_ARRAY;
867+
parameterType >= ParameterType.PARAMETER_NOT_SET &&
868+
parameterType <= ParameterType.PARAMETER_STRING_ARRAY;
870869

871870
return result;
872871
}
@@ -923,14 +922,11 @@ function _validArray(values, type) {
923922
arrayElementType = ParameterType.PARAMETER_BOOL;
924923
} else if (type === ParameterType.PARAMETER_BYTE_ARRAY) {
925924
arrayElementType = PARAMETER_BYTE;
926-
}
927-
if (type === ParameterType.PARAMETER_INTEGER_ARRAY) {
925+
} else if (type === ParameterType.PARAMETER_INTEGER_ARRAY) {
928926
arrayElementType = ParameterType.PARAMETER_INTEGER;
929-
}
930-
if (type === ParameterType.PARAMETER_DOUBLE_ARRAY) {
927+
} else if (type === ParameterType.PARAMETER_DOUBLE_ARRAY) {
931928
arrayElementType = ParameterType.PARAMETER_DOUBLE;
932-
}
933-
if (type === ParameterType.PARAMETER_STRING_ARRAY) {
929+
} else if (type === ParameterType.PARAMETER_STRING_ARRAY) {
934930
arrayElementType = ParameterType.PARAMETER_STRING;
935931
}
936932

lib/service.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
const rclnodejs = require('./native_loader.js');
1818
const DistroUtils = require('./distro.js');
1919
const Entity = require('./entity.js');
20+
const Logging = require('./logging.js');
2021
const debug = require('debug')('rclnodejs:service');
2122

2223
/**
@@ -75,8 +76,8 @@ class Service extends Entity {
7576

7677
const plainObj = request.toPlainObject(this.typedArrayEnabled);
7778
const response = new Response(this, headerHandle);
78-
Promise.resolve(this._callback(plainObj, response)).then(
79-
(responseToReturn) => {
79+
Promise.resolve(this._callback(plainObj, response))
80+
.then((responseToReturn) => {
8081
if (!response.sent && responseToReturn) {
8182
responseToReturn = new this._typeClass.Response(responseToReturn);
8283
const rawResponse = responseToReturn.serialize();
@@ -86,8 +87,11 @@ class Service extends Entity {
8687
debug(
8788
`Service has processed the ${this._serviceName} request and sent the response.`
8889
);
89-
}
90-
);
90+
})
91+
.catch((error) => {
92+
const logger = Logging.getLogger('rclnodejs');
93+
logger.error(`Error processing ${this._serviceName} request: ${error}`);
94+
});
9195
}
9296

9397
static createService(nodeHandle, serviceName, typeClass, options, callback) {

lib/time_source.js

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -45,26 +45,6 @@ class TimeSource {
4545
}
4646
}
4747

48-
get isRosTimeActive() {
49-
return this._isRosTimeActive;
50-
}
51-
52-
set isRosTimeActive(enabled) {
53-
if (this.isRosTimeActive === enabled) return;
54-
55-
this._isRosTimeActive = enabled;
56-
for (const clock in this._associatedClocks) {
57-
clock.isRosTimeActive = enabled;
58-
}
59-
60-
if (enabled) {
61-
this._subscribeToClockTopic();
62-
} else if (this._node && this._clockSubscription) {
63-
this._node.destroySubscription(this._clockSubscription);
64-
this._node._clockSubscription = null;
65-
}
66-
}
67-
6848
/**
6949
* Return status that whether the ROS time is active.
7050
* @name TimeSource#get:isRosTimeActive
@@ -93,6 +73,9 @@ class TimeSource {
9373
});
9474
if (enabled) {
9575
this._subscribeToClockTopic();
76+
} else if (this._node && this._clockSubscription) {
77+
this._node.destroySubscription(this._clockSubscription);
78+
this._clockSubscription = undefined;
9679
}
9780
}
9881

test/test-distro.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,12 @@ describe('rclnodejs distro utils', function () {
7878
process.env.ROS_DISTRO = backupEnvar;
7979
done();
8080
});
81+
82+
it('getDistroName with unknown id returns undefined', function () {
83+
assert.strictEqual(
84+
DistroUtils.getDistroName(99999),
85+
undefined,
86+
'getDistroName(99999) should return undefined, not throw'
87+
);
88+
});
8189
});

test/test-parameters.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,4 +748,84 @@ describe('rclnodejs parameters test suite', function () {
748748
assert.ok(paramFoundInParamList);
749749
});
750750
});
751+
752+
describe('parameterOverrides validation', function () {
753+
const NODE_NAME = 'test_node';
754+
let node;
755+
this.timeout(60 * 1000);
756+
757+
afterEach(function () {
758+
if (node) node.destroy();
759+
rclnodejs.shutdown();
760+
});
761+
762+
it('should reject non-Parameter objects in parameterOverrides', async function () {
763+
await rclnodejs.init();
764+
const options = new NodeOptions();
765+
options.parameterOverrides = [{ name: 'p1', value: 'not a Parameter' }];
766+
assert.throws(
767+
() =>
768+
rclnodejs.createNode(
769+
NODE_NAME,
770+
'',
771+
Context.defaultContext(),
772+
options
773+
),
774+
(err) => err instanceof rclnodejs.TypeValidationError,
775+
'Expected TypeValidationError for non-Parameter parameterOverride'
776+
);
777+
});
778+
});
779+
780+
describe('parameter type validation', function () {
781+
it('validType should reject invalid numeric types', function () {
782+
// A Parameter with an out-of-range type should throw on validate()
783+
assert.throws(() => {
784+
new Parameter('bad', 999, 'value').validate();
785+
});
786+
});
787+
788+
it('validType should accept all known ParameterTypes', function () {
789+
// NOT_SET type
790+
assert.doesNotThrow(() => {
791+
new Parameter('p', ParameterType.PARAMETER_NOT_SET).validate();
792+
});
793+
// BOOL type
794+
assert.doesNotThrow(() => {
795+
new Parameter('p', ParameterType.PARAMETER_BOOL, true).validate();
796+
});
797+
// STRING type
798+
assert.doesNotThrow(() => {
799+
new Parameter('p', ParameterType.PARAMETER_STRING, 'hello').validate();
800+
});
801+
});
802+
803+
it('array parameter validation', function () {
804+
assert.doesNotThrow(() => {
805+
new Parameter('p', ParameterType.PARAMETER_STRING_ARRAY, [
806+
'a',
807+
'b',
808+
]).validate();
809+
});
810+
assert.doesNotThrow(() => {
811+
new Parameter('p', ParameterType.PARAMETER_INTEGER_ARRAY, [
812+
1n,
813+
2n,
814+
]).validate();
815+
});
816+
assert.doesNotThrow(() => {
817+
new Parameter(
818+
'p',
819+
ParameterType.PARAMETER_DOUBLE_ARRAY,
820+
[1.0, 2.0]
821+
).validate();
822+
});
823+
assert.doesNotThrow(() => {
824+
new Parameter('p', ParameterType.PARAMETER_BOOL_ARRAY, [
825+
true,
826+
false,
827+
]).validate();
828+
});
829+
});
830+
});
751831
});

test/test-time-source.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -307,10 +307,9 @@ describe('rclnodejs TimeSource testing', function () {
307307
assert.strictEqual(timeSource.isRosTimeActive, true);
308308
assert.notStrictEqual(timeSource._clockSubscription, undefined);
309309

310-
// Disable - currently the implementation does NOT destroy subscription on disable
311-
// This test verifies current behavior (even if it might be considered a bug it ensures stability)
310+
// Disable - subscription should be destroyed and cleared
312311
timeSource.isRosTimeActive = false;
313312
assert.strictEqual(timeSource.isRosTimeActive, false);
314-
assert.notStrictEqual(timeSource._clockSubscription, undefined);
313+
assert.strictEqual(timeSource._clockSubscription, undefined);
315314
});
316315
});

0 commit comments

Comments
 (0)