Skip to content

Commit 55a6511

Browse files
authored
Generate missing messages at runtime (#1257)
This PR implements runtime message generation capabilities for rclnodejs, allowing custom ROS2 message types to be dynamically discovered, built, and loaded during execution rather than requiring pre-generation. - Adds runtime message discovery and generation functionality to the interface loader - Implements synchronous message generation using worker processes to avoid blocking the main thread - Includes comprehensive test infrastructure with a custom message package for validation Fix: #1257
1 parent 0448667 commit 55a6511

File tree

9 files changed

+375
-4
lines changed

9 files changed

+375
-4
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ To generate messages from IDL files, use the `generate-messages-idl` npm script:
140140
npm run generate-messages-idl
141141
```
142142

143+
\* This step is not needed for rclnodejs > 1.5.0
144+
143145
## Performance Benchmarks
144146

145147
Benchmark results for 1000 iterations with 1024KB messages (Ubuntu 24.04.3 WSL2, i7-1185G7):

lib/interface_loader.js

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,95 @@ let interfaceLoader = {
9090
return pkg;
9191
},
9292

93+
_isRos2InstallationPath(pkgPath) {
94+
// Use "which ros2" to dynamically find the ROS2 installation root
95+
try {
96+
const whichResult = require('child_process').spawnSync(
97+
'which',
98+
['ros2'],
99+
{
100+
encoding: 'utf8',
101+
timeout: 5000,
102+
}
103+
);
104+
105+
if (whichResult.status === 0 && whichResult.stdout) {
106+
const ros2BinPath = whichResult.stdout.trim();
107+
// Get the ROS2 installation root (typically /opt/ros/<distro> or similar)
108+
const ros2Root = path.dirname(path.dirname(ros2BinPath));
109+
110+
return pkgPath.includes(ros2Root);
111+
}
112+
} catch (err) {
113+
console.error('Error running which ros2:', err.message);
114+
// If "which ros2" fails, fall back to hardcoded check
115+
return pkgPath.includes('ros2-linux');
116+
}
117+
118+
return false;
119+
},
120+
121+
_searchAndGenerateInterface(packageName, type, messageName, filePath) {
122+
// Check if it's a valid package
123+
for (const pkgPath of generator.getInstalledPackagePaths()) {
124+
// We are going to ignore the path where ROS2 is installed.
125+
if (this._isRos2InstallationPath(pkgPath)) {
126+
continue;
127+
}
128+
129+
// Recursively search for files named messageName.* under pkgPath/
130+
if (fs.existsSync(pkgPath)) {
131+
// Recursive function to search for files
132+
function searchForFile(dir) {
133+
try {
134+
const items = fs.readdirSync(dir, { withFileTypes: true });
135+
for (const item of items) {
136+
const fullPath = path.join(dir, item.name);
137+
138+
if (item.isFile()) {
139+
const baseName = path.parse(item.name).name;
140+
// Check if the base filename matches messageName
141+
if (baseName === messageName) {
142+
return fullPath;
143+
}
144+
} else if (item.isDirectory()) {
145+
// Recursively search subdirectories
146+
const result = searchForFile(fullPath);
147+
if (result) {
148+
return result;
149+
}
150+
}
151+
}
152+
} catch (err) {
153+
// Skip directories we can't read
154+
console.error('Error reading directory:', dir, err.message);
155+
}
156+
return null;
157+
}
158+
159+
const foundFilePath = searchForFile(
160+
path.join(pkgPath, 'share', packageName)
161+
);
162+
163+
if (foundFilePath && foundFilePath.length > 0) {
164+
// Use worker thread to generate interfaces synchronously
165+
try {
166+
generator.generateInPathSyncWorker(pkgPath);
167+
// Now try to load the interface again from the generated files
168+
if (fs.existsSync(filePath)) {
169+
return require(filePath);
170+
}
171+
} catch (err) {
172+
console.error('Error in interface generation:', err);
173+
}
174+
}
175+
}
176+
}
177+
throw new Error(
178+
`The message required does not exist: ${packageName}, ${type}, ${messageName} at ${generator.generatedRoot}`
179+
);
180+
},
181+
93182
loadInterface(packageName, type, messageName) {
94183
if (arguments.length === 1) {
95184
const type = arguments[0];
@@ -100,7 +189,6 @@ let interfaceLoader = {
100189
}
101190
throw new Error(`The message required does not exist: ${type}`);
102191
}
103-
104192
if (packageName && type && messageName) {
105193
let filePath = path.join(
106194
generator.generatedRoot,
@@ -110,8 +198,16 @@ let interfaceLoader = {
110198

111199
if (fs.existsSync(filePath)) {
112200
return require(filePath);
201+
} else {
202+
return this._searchAndGenerateInterface(
203+
packageName,
204+
type,
205+
messageName,
206+
filePath
207+
);
113208
}
114209
}
210+
// We cannot parse `packageName`, `type` and `messageName` from the string passed.
115211
throw new Error(
116212
`The message required does not exist: ${packageName}, ${type}, ${messageName} at ${generator.generatedRoot}`
117213
);

rosidl_gen/generate_worker.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright (c) 2025, The Robot Web Tools Contributors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
const fse = require('fs-extra');
16+
const generateJSStructFromIDL = require('./idl_generator.js');
17+
const packages = require('./packages.js');
18+
const path = require('path');
19+
const idlConvertor = require('../rosidl_convertor/idl_convertor.js');
20+
21+
const generatedRoot = path.join(__dirname, '../generated/');
22+
const idlPath = path.join(generatedRoot, 'share');
23+
const useIDL = !!process.argv.find((arg) => arg === '--idl');
24+
25+
// Get target path from environment variable instead of workerData
26+
const targetPath = process.env.WORKER_TARGET_PATH;
27+
28+
async function generateInPath(targetPath) {
29+
let pkgsInfo = null;
30+
if (!useIDL) {
31+
pkgsInfo = Array.from(
32+
(await packages.findPackagesInDirectory(targetPath)).values()
33+
);
34+
} else {
35+
const idlPkgs = await packages.findPackagesInDirectory(targetPath, useIDL);
36+
await fse.ensureDir(idlPath);
37+
const promises = [];
38+
idlPkgs.forEach((pkg) => {
39+
pkg.idls.forEach((idl) => {
40+
promises.push(idlConvertor(idl.pkgName, idl.filePath, idlPath));
41+
});
42+
});
43+
await Promise.all(promises);
44+
const pkgsFromIdl = await packages.findPackagesInDirectory(idlPath, false);
45+
pkgsInfo = Array.from(pkgsFromIdl.values());
46+
}
47+
48+
await Promise.all(
49+
pkgsInfo.map((pkgInfo) => generateJSStructFromIDL(pkgInfo, generatedRoot))
50+
);
51+
}
52+
53+
async function main() {
54+
try {
55+
await generateInPath(targetPath);
56+
process.exit(0);
57+
} catch (error) {
58+
console.error('Worker generation failed:', error.message);
59+
process.exit(1);
60+
}
61+
}
62+
63+
main();

rosidl_gen/index.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,35 @@ async function generateInPath(path) {
5353
);
5454
}
5555

56+
function generateInPathSyncWorker(targetPath) {
57+
try {
58+
// Use child_process.spawnSync for truly synchronous execution
59+
const result = require('child_process').spawnSync(
60+
'node',
61+
[path.join(__dirname, 'generate_worker.js')],
62+
{
63+
env: { ...process.env, WORKER_TARGET_PATH: targetPath },
64+
encoding: 'utf8',
65+
timeout: 30000,
66+
}
67+
);
68+
69+
if (result.error) {
70+
throw result.error;
71+
}
72+
73+
if (result.status !== 0) {
74+
throw new Error(
75+
`Worker process exited with code ${result.status}. stderr: ${result.stderr}`
76+
);
77+
}
78+
79+
return result.stdout;
80+
} catch (error) {
81+
throw error;
82+
}
83+
}
84+
5685
async function generateAll(forcedGenerating) {
5786
// If we want to create the JavaScript files compulsively (|forcedGenerating| equals to true)
5887
// or the JavaScript files have not been created (|exist| equals to false),
@@ -86,7 +115,9 @@ const generator = {
86115

87116
generateAll,
88117
generateInPath,
118+
generateInPathSyncWorker,
89119
generatedRoot,
120+
getInstalledPackagePaths,
90121
};
91122

92123
module.exports = generator;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
cmake_minimum_required(VERSION 3.8)
2+
project(custom_msg_test)
3+
4+
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
5+
add_compile_options(-Wall -Wextra -Wpedantic)
6+
endif()
7+
8+
# find dependencies
9+
find_package(ament_cmake REQUIRED)
10+
find_package(std_msgs REQUIRED)
11+
find_package(builtin_interfaces REQUIRED)
12+
find_package(rosidl_default_generators REQUIRED)
13+
14+
# Generate interfaces
15+
rosidl_generate_interfaces(${PROJECT_NAME}
16+
"msg/Testing.msg"
17+
DEPENDENCIES std_msgs builtin_interfaces
18+
)
19+
20+
if(BUILD_TESTING)
21+
find_package(ament_lint_auto REQUIRED)
22+
# the following line skips the linter which checks for copyrights
23+
# comment the line when a copyright and license is added to all source files
24+
set(ament_cmake_copyright_FOUND TRUE)
25+
# the following line skips cpplint (only works in a git repo)
26+
# comment the line when this package is in a git repo and when
27+
# a copyright and license is added to all source files
28+
set(ament_cmake_cpplint_FOUND TRUE)
29+
ament_lint_auto_find_test_dependencies()
30+
endif()
31+
32+
ament_package()

test/custom_msg_test/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Custom Message Test Package
2+
3+
This ROS2 package contains custom message definitions for testing rclnodejs runtime message generation capabilities.
4+
5+
## Messages
6+
7+
### Testing.msg
8+
9+
A test message that contains the position of a point in free space with additional data:
10+
11+
- `float64 x` - X coordinate
12+
- `float64 y` - Y coordinate
13+
- `float64 z` - Z coordinate
14+
- `string data` - Additional string data
15+
16+
## Usage
17+
18+
This package is used by the rclnodejs test suite to verify that custom messages can be generated and used at runtime.
19+
20+
## Building
21+
22+
```bash
23+
# From the test directory
24+
cd custom_msg_test
25+
colcon build
26+
source install/setup.bash
27+
```
28+
29+
## Testing
30+
31+
The message definitions in this package are used by `test-rosidl-message-generator.js` to verify runtime message generation functionality.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# This contains the position of a point in free space
2+
float64 x
3+
float64 y
4+
float64 z
5+
string data

test/custom_msg_test/package.xml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?xml version="1.0"?>
2+
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
3+
<package format="3">
4+
<name>custom_msg_test</name>
5+
<version>1.0.0</version>
6+
<description>Custom message definitions for rclnodejs testing</description>
7+
<maintainer email="[email protected]">Test Maintainer</maintainer>
8+
<license>Apache-2.0</license>
9+
10+
<buildtool_depend>ament_cmake</buildtool_depend>
11+
12+
<depend>std_msgs</depend>
13+
<depend>builtin_interfaces</depend>
14+
15+
<build_depend>rosidl_default_generators</build_depend>
16+
<exec_depend>rosidl_default_runtime</exec_depend>
17+
18+
<member_of_group>rosidl_interface_packages</member_of_group>
19+
20+
<export>
21+
<build_type>ament_cmake</build_type>
22+
</export>
23+
</package>

0 commit comments

Comments
 (0)