Skip to content
Draft
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
1 change: 1 addition & 0 deletions modules/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ add_subdirectory(class_loader)
add_subdirectory(controller)
add_subdirectory(hardware)
add_subdirectory(estimator)
add_subdirectory(external_process)
add_subdirectory(command)
add_subdirectory(supervisor)
add_subdirectory(service)
Expand Down
38 changes: 38 additions & 0 deletions modules/external_process/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
cmake_minimum_required(VERSION 3.15)

# Allow the process tests to build without the rest of DLS or ROS installed.
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
project(dls_external_process LANGUAGES CXX)
enable_testing()
endif()

find_package(Boost REQUIRED COMPONENTS filesystem system)
find_package(Threads REQUIRED)

add_library(dls_external_process SHARED
src/managed_external_process.cpp
)
target_compile_features(dls_external_process PUBLIC cxx_std_17)

target_include_directories(dls_external_process
PUBLIC
include
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../utils/include>
)

target_link_libraries(dls_external_process
PUBLIC
Boost::filesystem
Boost::system
Threads::Threads
)

if(COMMAND dls_install)
dls_install(dls_external_process)
endif()

option(DLS_EXTERNAL_PROCESS_BUILD_TESTS "Build managed process integration tests" OFF)
if(DLS_EXTERNAL_PROCESS_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
50 changes: 50 additions & 0 deletions modules/external_process/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Managed external processes

`ManagedExternalProcess` keeps the asynchronous lifecycle API used by plugins:
`start()`, `running()`, `requestStop()`, `stopComplete()`, and `stopAndWait()`.
Repeated start/stop calls remain guarded. `running()` describes launcher liveness.

It delegates OS process ownership to `dls::utils::OwnedProcess` in
`dls2/util/owned_process.hpp`. That class creates a dedicated session/process group,
tracks leader and group liveness separately, and reaps children it owns.
`shutdownProcesses()` is shared with `run_dls2`:

- The framework interrupts the whole owned group.
- The plugin wrapper interrupts the launcher first, allowing ROS launch to stop
its nodes, then escalates to SIGTERM and SIGKILL for the whole group.
- The wrapper retains configurable interrupt/terminate intervals and uses a
two-second final wait after SIGKILL. Failure is propagated through the future;
the destructor logs it. It does not report success merely because SIGKILL was sent.

The owning executable must enable child subreaping if it needs to reap orphaned
descendants itself; `run_dls2` does so. The reusable wrapper does not change this
process-wide setting. A separately grouped descendant is not covered by its
ancestor's group signals if its immediate owner is forcibly killed.

`ShutdownSignal` handles incoming SIGINT/SIGTERM in `run_dls2` and
`child_process_launcher`. Its callback requests application shutdown, which reaches
the plugin's cleanup hooks. `shutdownProcesses()` sends outgoing signals to owned
children; the plugin wrapper does not install another signal handler.

## Integration test

From the PEGASUS repository root, build and run this module independently:

```sh
cmake -S dls2-barebone/dls2/modules/external_process \
-B /tmp/dls-external-process-tests \
-DDLS_EXTERNAL_PROCESS_BUILD_TESTS=ON
cmake --build /tmp/dls-external-process-tests -j2
ctest --test-dir /tmp/dls-external-process-tests --output-on-failure
```

Requires Linux, CMake, a C++17 compiler, and Boost filesystem/system development
libraries. ROS and the rest of DLS are not required for this standalone test build.


The test launches dummy child/grandchild processes and checks graceful shutdown,
forced shutdown of processes ignoring signals, cleanup after the launcher exits,
repeated start/stop requests, reactivation, destructor cleanup, and a missing
executable. It also checks session isolation, group liveness after leader exit,
both initial signal policies, and compatibility with the framework shutdown API.
It does not launch ROS or Nav2. Use `ctest -V` to see each scenario.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#ifndef MANAGED_EXTERNAL_PROCESS_HPP
#define MANAGED_EXTERNAL_PROCESS_HPP

#include <dls2/util/owned_process.hpp>
#include <chrono>
#include <future>
#include <memory>
#include <string>
#include <vector>

namespace dls
{
// Lifecycle methods are called serially by the plugin's state-machine thread.
class ManagedExternalProcess
{
public:
ManagedExternalProcess() = default;
~ManagedExternalProcess();
ManagedExternalProcess(const ManagedExternalProcess&) = delete;
ManagedExternalProcess& operator=(const ManagedExternalProcess&) = delete;

void start(const std::vector<std::string>& command,
std::chrono::milliseconds interrupt_timeout,
std::chrono::milliseconds terminate_timeout);
bool running();
void requestStop();
bool stopComplete();
void stopAndWait();

private:
void stop();
std::shared_ptr<utils::OwnedProcess> process_;
std::shared_future<void> stop_result_;
std::promise<void> stop_request_;
bool shutdown_sent_{false};
std::chrono::milliseconds interrupt_timeout_{15000};
std::chrono::milliseconds terminate_timeout_{5000};
};
}
#endif
116 changes: 116 additions & 0 deletions modules/external_process/src/managed_external_process.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#include "dls2/external_process/managed_external_process.hpp"

#include <iostream>
#include <stdexcept>
#include <system_error>
#include <pthread.h>
#include <sched.h>

using namespace dls;

ManagedExternalProcess::~ManagedExternalProcess()
{
try { stopAndWait(); }
catch (const std::exception& error)
{
std::cerr << "ManagedExternalProcess shutdown failed: " << error.what() << '\n';
// Attached Boost handles provide a final forced-termination fallback.
}
}

void ManagedExternalProcess::start(const std::vector<std::string>& command,
std::chrono::milliseconds interrupt_timeout,
std::chrono::milliseconds terminate_timeout)
{
if (process_ && !shutdown_sent_)
return;

if (shutdown_sent_ && stop_result_.valid())
stop_result_.get();

if (command.empty() || command.front().empty())
throw std::invalid_argument("Configure launch command string");

if (interrupt_timeout.count() < 0 || terminate_timeout.count() < 0)
throw std::invalid_argument("ManagedExternalProcess shutdown timeouts must be nonnegative");

const auto executable = command.front().find('/') == std::string::npos
? boost::process::search_path(command.front())
: boost::filesystem::path(command.front());

if (executable.empty())
throw std::runtime_error("Launch executable not found: " + command.front());

auto args = command;
args.front() = executable.string();
process_ = std::make_shared<utils::OwnedProcess>(args);
interrupt_timeout_ = interrupt_timeout;
terminate_timeout_ = terminate_timeout;
stop_result_ = {};
shutdown_sent_ = false;
stop_request_ = std::promise<void>{};
try
{
// Activation runs outside SCHED_DEADLINE. Create the worker here:
// a deadline thread cannot create it later from requestStop().
stop_result_ = std::async(std::launch::async,
[this, request = stop_request_.get_future()]() mutable {
request.wait();
stop();
}).share();
}
catch (...)
{
stop();
throw;
}
}

bool ManagedExternalProcess::running()
{
return !shutdown_sent_ && process_ && process_->leaderRunning();
}

void ManagedExternalProcess::requestStop()
{
if (shutdown_sent_)
return;

if (stop_result_.valid()) stop_request_.set_value();
shutdown_sent_ = true;
}

bool ManagedExternalProcess::stopComplete()
{
if (!shutdown_sent_) return false;
if (!stop_result_.valid()) return true;
if (stop_result_.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready)
return false;

stop_result_.get();
return true;
}

void ManagedExternalProcess::stopAndWait()
{
requestStop();
if (stop_result_.valid()) stop_result_.get();
}

void ManagedExternalProcess::stop()
{
if (!process_) return;
sched_param parameters{};
const int scheduler_error = ::pthread_setschedparam(::pthread_self(), SCHED_OTHER, &parameters);
if (scheduler_error != 0)
std::cerr << "External group shutdown worker could not select SCHED_OTHER: "
<< std::generic_category().message(scheduler_error) << '\n';
utils::ProcessShutdownOptions options;
options.interrupt_timeout = interrupt_timeout_;
options.terminate_timeout = terminate_timeout_;
options.initial_target = utils::InitialSignalTarget::leader;
utils::OwnedProcesses processes{{"external process", process_}};
if (!utils::shutdownProcesses(processes, options))
throw std::runtime_error("External process group did not exit after SIGKILL");
process_.reset();
}
7 changes: 7 additions & 0 deletions modules/external_process/tests/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
add_executable(test_external_process_managed_process managed_external_process_test.cpp)
target_link_libraries(test_external_process_managed_process PRIVATE dls_external_process)
add_test(NAME external_process_managed_process COMMAND test_external_process_managed_process)
set_tests_properties(external_process_managed_process PROPERTIES TIMEOUT 15)
if(TARGET dls2-tests)
add_dependencies(dls2-tests test_external_process_managed_process)
endif()
Loading