From ae1bdc080f7b655c15c8f4b61ea083cf1b7a46bf Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Wed, 27 May 2026 10:35:11 +0800 Subject: [PATCH 1/3] Add enable_logger_service option for runtime log level control --- lib/logging_service.js | 108 ++++++++++++++++++++++++++++++++ lib/node.js | 9 +++ lib/node_options.js | 22 ++++++- test/test-logging-service.js | 116 +++++++++++++++++++++++++++++++++++ test/test-node-options.js | 4 ++ test/types/index.test-d.ts | 1 + types/node_options.d.ts | 7 +++ 7 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 lib/logging_service.js create mode 100644 test/test-logging-service.js diff --git a/lib/logging_service.js b/lib/logging_service.js new file mode 100644 index 000000000..69b0c4521 --- /dev/null +++ b/lib/logging_service.js @@ -0,0 +1,108 @@ +'use strict'; + +const Logging = require('./logging.js'); + +const LOGGING_SEVERITY_UNSET = 0; + +/** + * Implements the ROS 2 logging service interfaces for a node. + * + * The interfaces implemented are: + * rcl_interfaces/srv/GetLoggerLevels + * rcl_interfaces/srv/SetLoggerLevels + * + * @class + */ +class LoggingService { + /** + * Create a new instance. + * @param {Node} node - The node these services support. + */ + constructor(node) { + this._node = node; + this._isRunning = false; + } + + /** + * Get the node this service supports. + * @return {Node} - The supported node. + */ + get node() { + return this._node; + } + + /** + * Check if logging services are configured and accepting requests. + * @return {boolean} - True if services are active; false otherwise. + */ + isStarted() { + return this._isRunning; + } + + /** + * Configure logging services and begin processing client requests. + * @return {undefined} + */ + start() { + if (this._isRunning) return; + + this._isRunning = true; + const nodeName = this.node.name(); + + this.node.createService( + 'rcl_interfaces/srv/GetLoggerLevels', + nodeName + '/get_logger_levels', + (request, response) => this._handleGetLoggerLevels(request, response) + ); + + this.node.createService( + 'rcl_interfaces/srv/SetLoggerLevels', + nodeName + '/set_logger_levels', + (request, response) => this._handleSetLoggerLevels(request, response) + ); + } + + _handleGetLoggerLevels(request, response) { + const msg = response.template; + + for (const name of request.names) { + try { + msg.levels.push({ + name, + level: Logging.getLogger(name).loggerEffectiveLevel, + }); + } catch { + msg.levels.push({ + name, + level: LOGGING_SEVERITY_UNSET, + }); + } + } + + response.send(msg); + } + + _handleSetLoggerLevels(request, response) { + const msg = response.template; + + for (const loggerLevel of request.levels) { + const result = { + successful: false, + reason: '', + }; + + try { + Logging.getLogger(loggerLevel.name).setLoggerLevel(loggerLevel.level); + result.successful = true; + } catch (error) { + result.reason = error.message; + } + + msg.results.push(result); + } + + response.send(msg); + } +} + +module.exports = LoggingService; diff --git a/lib/node.js b/lib/node.js index d1b49fe0c..d3ece9e0f 100644 --- a/lib/node.js +++ b/lib/node.js @@ -25,6 +25,7 @@ const DistroUtils = require('./distro.js'); const GuardCondition = require('./guard_condition.js'); const loader = require('./interface_loader.js'); const Logging = require('./logging.js'); +const LoggingService = require('./logging_service.js'); const NodeOptions = require('./node_options.js'); const { ParameterType, @@ -121,6 +122,8 @@ class Node extends rclnodejs.ShadowNode { defaults.startTypeDescriptionService, enableRosout: options.enableRosout ?? defaults.enableRosout, rosoutQos: options.rosoutQos ?? defaults.rosoutQos, + enableLoggerService: + options.enableLoggerService ?? defaults.enableLoggerService, }; } @@ -159,6 +162,7 @@ class Node extends rclnodejs.ShadowNode { this._parameterDescriptors = new Map(); this._parameters = new Map(); this._parameterService = null; + this._loggerService = null; this._typeDescriptionService = null; this._parameterEventPublisher = null; this._preSetParametersCallbacks = []; @@ -219,6 +223,11 @@ class Node extends rclnodejs.ShadowNode { this._parameterService.start(); } + if (options.enableLoggerService) { + this._loggerService = new LoggingService(this); + this._loggerService.start(); + } + if ( DistroUtils.getDistroId() >= DistroUtils.getDistroId('jazzy') && options.startTypeDescriptionService diff --git a/lib/node_options.js b/lib/node_options.js index e44140e0a..bc474bda1 100644 --- a/lib/node_options.js +++ b/lib/node_options.js @@ -29,6 +29,7 @@ class NodeOptions { * @param {boolean} [startTypeDescriptionService=true] * @param {boolean} [enableRosout=true] * @param {QoS} [rosoutQos=QoS.profileDefault] + * @param {boolean} [enableLoggerService=false] */ constructor( startParameterServices = true, @@ -36,7 +37,8 @@ class NodeOptions { automaticallyDeclareParametersFromOverrides = false, startTypeDescriptionService = true, enableRosout = true, - rosoutQos = null + rosoutQos = null, + enableLoggerService = false ) { this._startParameterServices = startParameterServices; this._parameterOverrides = parameterOverrides; @@ -45,6 +47,7 @@ class NodeOptions { this._startTypeDescriptionService = startTypeDescriptionService; this._enableRosout = enableRosout; this._rosoutQos = rosoutQos; + this._enableLoggerService = enableLoggerService; } /** @@ -164,6 +167,23 @@ class NodeOptions { this._rosoutQos = rosoutQos; } + /** + * Get the enableLoggerService option. + * Default value = false; + * @returns {boolean} - true if logger services are enabled. + */ + get enableLoggerService() { + return this._enableLoggerService; + } + + /** + * Set enableLoggerService. + * @param {boolean} enableLoggerService + */ + set enableLoggerService(enableLoggerService) { + this._enableLoggerService = enableLoggerService; + } + /** * Return an instance configured with default options. * @returns {NodeOptions} - An instance with default values. diff --git a/test/test-logging-service.js b/test/test-logging-service.js new file mode 100644 index 000000000..4f7e67ae8 --- /dev/null +++ b/test/test-logging-service.js @@ -0,0 +1,116 @@ +// 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 sinon = require('sinon'); +const Logging = require('../lib/logging.js'); +const LoggingService = require('../lib/logging_service.js'); + +const LOGGING_SEVERITY = { + UNSET: 0, + DEBUG: 10, + INFO: 20, +}; + +describe('LoggingService test suite', function () { + let sandbox; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('starts get_logger_levels and set_logger_levels services', function () { + const node = { + name: () => 'logger_node', + createService: sandbox.stub(), + }; + const service = new LoggingService(node); + + service.start(); + service.start(); + + assert.strictEqual(service.isStarted(), true); + assert.strictEqual(node.createService.callCount, 2); + assert.deepStrictEqual(node.createService.firstCall.args.slice(0, 2), [ + 'rcl_interfaces/srv/GetLoggerLevels', + 'logger_node/get_logger_levels', + ]); + assert.deepStrictEqual(node.createService.secondCall.args.slice(0, 2), [ + 'rcl_interfaces/srv/SetLoggerLevels', + 'logger_node/set_logger_levels', + ]); + }); + + it('returns logger levels and maps lookup failures to UNSET', function () { + sandbox.stub(Logging, 'getLogger').callsFake((name) => { + if (name === 'missing_logger') { + throw new Error('logger not found'); + } + return { loggerEffectiveLevel: LOGGING_SEVERITY.INFO }; + }); + const service = new LoggingService({}); + const response = { + template: { levels: [] }, + send: sandbox.spy(), + }; + + service._handleGetLoggerLevels( + { names: ['existing_logger', 'missing_logger'] }, + response + ); + + assert.strictEqual(response.send.calledOnce, true); + assert.deepStrictEqual(response.send.firstCall.args[0].levels, [ + { name: 'existing_logger', level: LOGGING_SEVERITY.INFO }, + { name: 'missing_logger', level: LOGGING_SEVERITY.UNSET }, + ]); + }); + + it('sets logger levels and reports per-level failures', function () { + sandbox.stub(Logging, 'getLogger').callsFake((name) => ({ + setLoggerLevel(level) { + if (name === 'bad_logger') { + throw new Error(`failed to set ${level}`); + } + }, + })); + const service = new LoggingService({}); + const response = { + template: { results: [] }, + send: sandbox.spy(), + }; + + service._handleSetLoggerLevels( + { + levels: [ + { name: 'good_logger', level: LOGGING_SEVERITY.DEBUG }, + { name: 'bad_logger', level: 999 }, + ], + }, + response + ); + + assert.strictEqual(response.send.calledOnce, true); + assert.deepStrictEqual(response.send.firstCall.args[0].results, [ + { successful: true, reason: '' }, + { successful: false, reason: 'failed to set 999' }, + ]); + }); +}); diff --git a/test/test-node-options.js b/test/test-node-options.js index a5efe4bc5..061f564c7 100644 --- a/test/test-node-options.js +++ b/test/test-node-options.js @@ -35,6 +35,7 @@ describe('rclnodejs NodeOptions test suite', function () { nodeOptions.automaticallyDeclareParametersFromOverrides, false ); + assert.strictEqual(nodeOptions.enableLoggerService, false); assert.ok(Array.isArray(nodeOptions.parameterOverrides)); assert.strictEqual(nodeOptions.parameterOverrides.length, 0); }); @@ -47,6 +48,7 @@ describe('rclnodejs NodeOptions test suite', function () { nodeOptions.automaticallyDeclareParametersFromOverrides, false ); + assert.strictEqual(nodeOptions.enableLoggerService, false); assert.ok(Array.isArray(nodeOptions.parameterOverrides)); assert.strictEqual(nodeOptions.parameterOverrides.length, 0); }); @@ -61,6 +63,7 @@ describe('rclnodejs NodeOptions test suite', function () { nodeOptions.startParameterServices = false; nodeOptions.automaticallyDeclareParametersFromOverrides = true; + nodeOptions.enableLoggerService = true; nodeOptions.parameterOverrides = param; assert.strictEqual(nodeOptions.startParameterServices, false); @@ -68,6 +71,7 @@ describe('rclnodejs NodeOptions test suite', function () { nodeOptions.automaticallyDeclareParametersFromOverrides, true ); + assert.strictEqual(nodeOptions.enableLoggerService, true); assert.ok(Array.isArray(nodeOptions.parameterOverrides)); assert.strictEqual(nodeOptions.parameterOverrides.length, 1); assert.strictEqual(nodeOptions.parameterOverrides[0].name, 'str_param'); diff --git a/test/types/index.test-d.ts b/test/types/index.test-d.ts index 8439bab7c..e067ad7e4 100644 --- a/test/types/index.test-d.ts +++ b/test/types/index.test-d.ts @@ -64,6 +64,7 @@ expectType(nodeOptions.automaticallyDeclareParametersFromOverrides); expectType(nodeOptions.parameterOverrides); expectType(nodeOptions.enableRosout); expectType(nodeOptions.rosoutQos); +expectType(nodeOptions.enableLoggerService); // ---- Node ----- const node = rclnodejs.createNode(NODE_NAME); diff --git a/types/node_options.d.ts b/types/node_options.d.ts index b1c7e0b33..0e3e15c62 100644 --- a/types/node_options.d.ts +++ b/types/node_options.d.ts @@ -45,6 +45,13 @@ declare module 'rclnodejs' { */ rosoutQos: QoS | QoS.ProfileRef; + /** + * A flag controlling the startup of logger services. + * When true a node will start get_logger_levels and set_logger_levels services. + * Default value = false; + */ + enableLoggerService: boolean; + /** * An instance configured with default values. */ From 29c49462683331aa28a9d3cbec26863bbf1ac628 Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Wed, 27 May 2026 13:10:49 +0800 Subject: [PATCH 2/3] Address comments --- lib/logging_service.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/logging_service.js b/lib/logging_service.js index 69b0c4521..781dc4966 100644 --- a/lib/logging_service.js +++ b/lib/logging_service.js @@ -1,3 +1,17 @@ +// 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 Logging = require('./logging.js'); From 4078568a3d704dc4529fcdf372f6f3bfa2a0bc75 Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Wed, 27 May 2026 13:50:17 +0800 Subject: [PATCH 3/3] Address comments --- .../workflows/linux-arm64-build-and-test.yml | 19 ++++++--- .github/workflows/linux-x64-asan-test.yml | 15 +++++-- .../workflows/linux-x64-build-and-test.yml | 41 +++++++++++++------ .github/workflows/windows-build-and-test.yml | 20 ++++++--- 4 files changed, 69 insertions(+), 26 deletions(-) diff --git a/.github/workflows/linux-arm64-build-and-test.yml b/.github/workflows/linux-arm64-build-and-test.yml index ea5304b13..17870241b 100644 --- a/.github/workflows/linux-arm64-build-and-test.yml +++ b/.github/workflows/linux-arm64-build-and-test.yml @@ -95,9 +95,18 @@ jobs: # Fix ownership of the workspace to allow write access sudo chown -R $(whoami):$(whoami) $GITHUB_WORKSPACE + # Wrap the test step in nick-fields/retry@v4 to absorb transient flakes + # (mocha races, native rebuilds, Electron postinstall, network blips). + # Keep max_attempts low so real regressions still surface quickly. - name: Build and test rclnodejs - run: | - uname -a - source /opt/ros/${{ matrix.ros_distribution }}/setup.bash - npm i - npm test + uses: nick-fields/retry@v4 + with: + shell: bash + max_attempts: 2 + retry_wait_seconds: 10 + timeout_minutes: 60 + command: | + uname -a + source /opt/ros/${{ matrix.ros_distribution }}/setup.bash + npm i + npm test diff --git a/.github/workflows/linux-x64-asan-test.yml b/.github/workflows/linux-x64-asan-test.yml index 6c2e2baf6..19c0bedff 100644 --- a/.github/workflows/linux-x64-asan-test.yml +++ b/.github/workflows/linux-x64-asan-test.yml @@ -41,7 +41,16 @@ jobs: source /opt/ros/jazzy/setup.bash npm i + # Wrap the asan test step in nick-fields/retry@v4 to absorb transient + # flakes. ASan can be sensitive to timing-related test races; keep + # max_attempts low so real regressions still surface quickly. - name: Build and test with AddressSanitizer - run: | - source /opt/ros/jazzy/setup.bash - npm run test:asan + uses: nick-fields/retry@v4 + with: + shell: bash + max_attempts: 2 + retry_wait_seconds: 10 + timeout_minutes: 60 + command: | + source /opt/ros/jazzy/setup.bash + npm run test:asan diff --git a/.github/workflows/linux-x64-build-and-test.yml b/.github/workflows/linux-x64-build-and-test.yml index a389fd724..fcf6b1444 100644 --- a/.github/workflows/linux-x64-build-and-test.yml +++ b/.github/workflows/linux-x64-build-and-test.yml @@ -123,22 +123,39 @@ jobs: - uses: actions/checkout@v6 + # The mocha suite and the Electron postinstall (extract-zip 2.0.1) are + # known to be intermittently flaky on this runner. Wrap both test + # invocations in nick-fields/retry@v4 so a single transient failure does + # not fail the whole matrix leg. Keep max_attempts conservative so real + # regressions still surface quickly. - name: Build and test rclnodejs - run: | - uname -a - source /opt/ros/${{ matrix.ros_distribution }}/setup.bash - npm i - npm run lint - npm test - npm run clean + uses: nick-fields/retry@v4 + with: + shell: bash + max_attempts: 2 + retry_wait_seconds: 10 + timeout_minutes: 60 + command: | + uname -a + source /opt/ros/${{ matrix.ros_distribution }}/setup.bash + npm i + npm run lint + npm test + npm run clean - name: Test with IDL ROS messages (rolling / lyrical) if: ${{ matrix.ros_distribution == 'rolling' || matrix.ros_distribution == 'lyrical' }} - run: | - source /opt/ros/${{ matrix.ros_distribution }}/setup.bash - npm i - npm run test-idl - npm run clean + uses: nick-fields/retry@v4 + with: + shell: bash + max_attempts: 2 + retry_wait_seconds: 10 + timeout_minutes: 60 + command: | + source /opt/ros/${{ matrix.ros_distribution }}/setup.bash + npm i + npm run test-idl + npm run clean - name: Coveralls if: ${{ matrix.ros_distribution == 'rolling' && matrix['node-version'] == '24.X' }} diff --git a/.github/workflows/windows-build-and-test.yml b/.github/workflows/windows-build-and-test.yml index 207626a7f..3ffe25fbb 100644 --- a/.github/workflows/windows-build-and-test.yml +++ b/.github/workflows/windows-build-and-test.yml @@ -88,11 +88,19 @@ jobs: call "C:\pixi_ws\ros2-windows\setup.bat" npm i + # Wrap the test step in nick-fields/retry@v4 to absorb transient flakes + # (mocha races, native rebuilds, network blips). Keep max_attempts low so + # real regressions still surface quickly. - name: Test rclnodejs if: ${{ matrix.run_tests }} - shell: cmd - run: | - set PATH=C:\pixi_ws\.pixi\envs\default\Library\bin;C:\pixi_ws\.pixi\envs\default\Scripts;C:\pixi_ws\.pixi\envs\default\bin;%PATH% - set RMW_IMPLEMENTATION=rmw_fastrtps_cpp - call "C:\pixi_ws\ros2-windows\setup.bat" - npm test + uses: nick-fields/retry@v4 + with: + shell: cmd + max_attempts: 2 + retry_wait_seconds: 10 + timeout_minutes: 60 + command: | + set PATH=C:\pixi_ws\.pixi\envs\default\Library\bin;C:\pixi_ws\.pixi\envs\default\Scripts;C:\pixi_ws\.pixi\envs\default\bin;%PATH% + set RMW_IMPLEMENTATION=rmw_fastrtps_cpp + call "C:\pixi_ws\ros2-windows\setup.bat" + npm test