Skip to content

Commit c3d23cb

Browse files
committed
Generate missing messages at runtime
1 parent 0448667 commit c3d23cb

File tree

8 files changed

+339
-4
lines changed

8 files changed

+339
-4
lines changed

lib/interface_loader.js

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

93+
_searchAndGenerateInterface(packageName, type, messageName, filePath) {
94+
// Check if it's a valid package
95+
for (const pkgPath of generator.getInstalledPackagePaths()) {
96+
if (pkgPath.includes('ros2-linux')) {
97+
continue;
98+
}
99+
100+
// Recursively search for files named messageName.* under pkgPath/
101+
if (fs.existsSync(pkgPath)) {
102+
// Recursive function to search for files
103+
function searchForFile(dir) {
104+
try {
105+
const items = fs.readdirSync(dir, { withFileTypes: true });
106+
for (const item of items) {
107+
const fullPath = path.join(dir, item.name);
108+
109+
if (item.isFile()) {
110+
const baseName = path.parse(item.name).name;
111+
// Check if the base filename matches messageName
112+
if (baseName === messageName) {
113+
return fullPath;
114+
}
115+
} else if (item.isDirectory()) {
116+
// Recursively search subdirectories
117+
const result = searchForFile(fullPath);
118+
if (result) {
119+
return result;
120+
}
121+
}
122+
}
123+
} catch (err) {
124+
// Skip directories we can't read
125+
console.log('Error reading directory:', dir, err.message);
126+
}
127+
return null;
128+
}
129+
130+
const foundFilePath = searchForFile(
131+
path.join(pkgPath, 'share', packageName)
132+
);
133+
if (foundFilePath && foundFilePath.length > 0) {
134+
// Use worker thread to generate interfaces synchronously
135+
try {
136+
generator.generateInPathSyncWorker(pkgPath);
137+
// Now try to load the interface again from the generated files
138+
if (fs.existsSync(filePath)) {
139+
return require(filePath);
140+
}
141+
} catch (err) {
142+
console.error('Error in interface generation:', err);
143+
}
144+
} else {
145+
throw new Error(
146+
`The message required does not exist: ${packageName}, ${type}, ${messageName} at ${generator.generatedRoot}`
147+
);
148+
}
149+
}
150+
}
151+
throw new Error(
152+
`The message required does not exist: ${packageName}, ${type}, ${messageName} at ${generator.generatedRoot}`
153+
);
154+
},
155+
93156
loadInterface(packageName, type, messageName) {
94157
if (arguments.length === 1) {
95158
const type = arguments[0];
@@ -100,7 +163,6 @@ let interfaceLoader = {
100163
}
101164
throw new Error(`The message required does not exist: ${type}`);
102165
}
103-
104166
if (packageName && type && messageName) {
105167
let filePath = path.join(
106168
generator.generatedRoot,
@@ -110,8 +172,16 @@ let interfaceLoader = {
110172

111173
if (fs.existsSync(filePath)) {
112174
return require(filePath);
175+
} else {
176+
return this._searchAndGenerateInterface(
177+
packageName,
178+
type,
179+
messageName,
180+
filePath
181+
);
113182
}
114183
}
184+
// We cannot parse `packageName`, `type` and `messageName` from the string passed.
115185
throw new Error(
116186
`The message required does not exist: ${packageName}, ${type}, ${messageName} at ${generator.generatedRoot}`
117187
);

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>

test/test-rosidl-message-generator.js

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,77 @@
1717
const assert = require('assert');
1818
const os = require('os');
1919
const rclnodejs = require('../index.js');
20+
const path = require('path');
21+
22+
function buildTestMessage() {
23+
// Build the custom_msg_test package synchronously before running the test
24+
const customMsgTestPath = path.join(__dirname, 'custom_msg_test');
25+
const buildResult = require('child_process').spawnSync('colcon', ['build'], {
26+
cwd: customMsgTestPath,
27+
stdio: 'inherit',
28+
timeout: 60000, // 60 second timeout
29+
});
30+
31+
if (buildResult.error) {
32+
throw new Error(
33+
`Failed to build custom_msg_test package: ${buildResult.error.message}`
34+
);
35+
}
36+
37+
if (buildResult.status !== 0) {
38+
throw new Error(`colcon build failed with exit code ${buildResult.status}`);
39+
}
40+
41+
// Source the local_setup.sh to get environment variables
42+
const setupScriptPath = path.join(
43+
customMsgTestPath,
44+
'install',
45+
'local_setup.sh'
46+
);
47+
const sourceResult = require('child_process').spawnSync(
48+
'bash',
49+
['-c', `source ${setupScriptPath} && env`],
50+
{
51+
encoding: 'utf8',
52+
timeout: 10000, // 10 second timeout
53+
}
54+
);
55+
56+
if (sourceResult.error) {
57+
throw new Error(
58+
`Failed to source setup script: ${sourceResult.error.message}`
59+
);
60+
}
61+
62+
if (sourceResult.status !== 0) {
63+
throw new Error(
64+
`Failed to source setup script with exit code ${sourceResult.status}`
65+
);
66+
}
67+
68+
// Parse and apply environment variables to current process
69+
const envOutput = sourceResult.stdout;
70+
const envLines = envOutput.split('\n');
71+
72+
envLines.forEach((line) => {
73+
const equalIndex = line.indexOf('=');
74+
if (equalIndex > 0) {
75+
const key = line.substring(0, equalIndex);
76+
const value = line.substring(equalIndex + 1);
77+
78+
// Only update AMENT_PREFIX_PATH from the subprocess
79+
if (key === 'AMENT_PREFIX_PATH') {
80+
process.env[key] = value;
81+
}
82+
}
83+
});
84+
}
2085

2186
describe('ROSIDL Node.js message generator test suite', function () {
22-
before(function () {
23-
this.timeout(60 * 1000);
24-
return rclnodejs.init();
87+
this.timeout(60 * 1000);
88+
89+
before(async function () {
90+
await rclnodejs.init();
2591
});
2692

2793
after(function () {
@@ -220,4 +286,18 @@ describe('ROSIDL Node.js message generator test suite', function () {
220286
assert.equal(array.size, 6);
221287
assert.equal(array.capacity, 6);
222288
});
289+
290+
it('Generate message at runtime', function () {
291+
const amentPrefixPathOriginal = process.env.AMENT_PREFIX_PATH;
292+
buildTestMessage();
293+
294+
assert.doesNotThrow(() => {
295+
const Testing = rclnodejs.require('custom_msg_test/msg/Testing');
296+
const t = new Testing();
297+
assert.equal(typeof t, 'object');
298+
assert.equal(typeof t.x, 'number');
299+
assert.equal(typeof t.data, 'string');
300+
}, 'This function should not throw');
301+
process.env.AMENT_PREFIX_PATH = amentPrefixPathOriginal;
302+
});
223303
});

0 commit comments

Comments
 (0)