refactor(platform): lanceur de processus commun, enfant lié au parent

Le lancement de processus de Studio host (CoreProcess) devient sequa::platform::ChildProcess dans platform/process, pour être partagé par Studio host et le Core (qui lancera le rendu, ADR 0009). Nouvelle option kill_with_parent : l'enfant meurt avec le parent, même brutalement (job object KILL_ON_JOB_CLOSE sous Windows, fork + PR_SET_PDEATHSIG sous Linux, ignorée sous macOS). Les tests de copie fantôme restent dans studio/host/supervisor (test_shadow.cpp) avec l'enfant de test de platform/process.
This commit is contained in:
Ange-Marie MAURIN - A2M Systèmes committed 2026-11-07 14:41:56 +01:00
1 parent c6aa05680e
commit 464ff6ce88
14 files changed
+447 -123

No files matched your search

+1
View File
@@ -31,6 +31,7 @@ if(SEQUA_BUILD_TESTS)
enable_testing()
endif()
add_subdirectory(platform/process) # lancement de processus enfants (Studio host, Core)
add_subdirectory(core)
add_subdirectory(3d)
add_subdirectory(engines/godot/adapter) # Godot lui-même n'est pas compilé ici (SCons, scripts/build-godot.ps1)
+37
View File
@@ -0,0 +1,37 @@
# SPDX-License-Identifier: MPL-2.0
#
# Brique processus commune : lancement et supervision d'un processus enfant
# (ex-supervisor du Studio host, ADR 0009). Utilisée par le Studio host, le Core
# (lancement du processus de rendu) et le rendu lui-même.
add_library(sequa_process
src/process.cpp)
add_library(sequa::process ALIAS sequa_process)
target_include_directories(sequa_process
PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include")
target_compile_features(sequa_process PUBLIC cxx_std_20)
# Aucune bibliothèque système à lier à part Windows.h (kernel32 est implicite) ;
# bcrypt et shell32 ne servent qu'à random et aux autres briques du host.
sequa_target_defaults(sequa_process)
if(SEQUA_BUILD_TESTS)
# Processus enfant de test : écrit, attend, sort avec un code donné.
add_executable(sequa_process_test_child tests/test_child.cpp)
if(WIN32)
target_link_libraries(sequa_process_test_child PRIVATE shell32)
endif()
sequa_target_defaults(sequa_process_test_child)
add_executable(sequa_process_tests
tests/main.cpp
tests/test_process.cpp)
target_link_libraries(sequa_process_tests PRIVATE sequa::process doctest::doctest)
target_compile_definitions(sequa_process_tests PRIVATE
SEQUA_TEST_CHILD="$<TARGET_FILE:sequa_process_test_child>")
add_dependencies(sequa_process_tests sequa_process_test_child)
sequa_target_defaults(sequa_process_tests)
doctest_discover_tests(sequa_process_tests)
endif()
+24
View File
@@ -0,0 +1,24 @@
# Plateforme : processus
Brique commune de lancement et de supervision d'un processus enfant : le Studio
host l'utilise pour le Core ([ADR 0006](../../docs/adr/0006-studio-host-et-cycle-de-vie-du-core.md),
[spécification Studio - Core §7](../../docs/specs/protocole-studio-core-0.2.md)),
le Core la réutilise pour lancer le processus de rendu ([ADR 0009](../../docs/adr/0009-processus-de-rendu-lance-par-le-core.md)),
`sequa-render` en disposera pareil pour ses propres enfants.
## API (`sequa::platform`)
| Pièce | Rôle |
|---|---|
| `current_process_id` | PID du processus courant (nommage des copies fantômes, etc.) |
| `is_process_alive` | Un PID est-il encore vivant ? Pré-validation seulement (ADR 0006 : la poignée de main reste l'arbitre) |
| `LaunchOptions` | Journal en ajout (stdout et stderr fenêtres), console propre (`--core-console`), `kill_with_parent` (« mourir avec le parent » : job object `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` sous Windows, `fork` + `PR_SET_PDEATHSIG` sous Linux ; ignoré sous macOS pour l'instant) |
| `ChildProcess` | Lancement (arguments correctement cités sous Windows, journal en ajout, seul le handle du journal est hérité sous Windows), `running`, `wait_for`, `terminate` ; le destructeur NE tue PAS le processus (le cycle de vie appartient à l'appelant, §7.6) |
Le code POSIX (`posix_spawn`, `waitpid`, 0600) est écrit mais n'a pas encore été
compilé ni testé (vérification locale Windows uniquement, ADR 0004).
## Licences
- MPL 2.0.
- Aucune dépendance externe (doctest seul, pour les tests : MIT).
@@ -8,9 +8,10 @@
#include <string>
#include <vector>
namespace sequa::studio::host {
namespace sequa::platform {
// PID du processus courant (utilisé pour nommer les copies fantômes, §7.5).
// PID du processus courant (utilisé pour nommer les copies fantômes et vérifier
// un processus, §7.5 du protocole Studio - Core).
[[nodiscard]] std::uint32_t current_process_id();
// Un PID désigne-t-il un processus encore vivant ? Pré-validation d'un fichier
@@ -19,10 +20,10 @@ namespace sequa::studio::host {
// un processus d'un autre utilisateur (EPERM) est considéré vivant.
[[nodiscard]] bool is_process_alive(std::uint32_t pid);
// Options de lancement (§7.5).
// Options de lancement (§7.5 du protocole Studio - Core).
struct LaunchOptions {
// Journal où rediriger stdout ET stderr EN AJOUT. Si non vide, une ligne
// d'en-tête horodatée est écrite par le superviseur AVANT le lancement.
// d'en-tête horodatée est écrite par le lanceur AVANT le lancement.
// Le fichier est créé si absent ; son dossier doit exister.
std::filesystem::path log_path;
@@ -31,32 +32,43 @@ struct LaunchOptions {
// §7.1). Ignoré sous POSIX (pas de notion de console). false : aucune
// fenêtre (CREATE_NO_WINDOW sous Windows).
bool own_console = false;
// true : l'enfant meurt dès que le processus lanceur meurt, même brutalement
// (plantage, TerminateProcess) : jamais d'orphelin bloqué (ADR 0009). Sous
// Windows : l'enfant est affecté à un job object du parent, créé au premier
// lancement (std::call_once) avec JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE et
// jamais refermé : quand le parent meurt, la table de handles disparaît, le
// handle du job se ferme et le système tue tout le job. Linux : posix_spawn
// ne permet pas de PR_SET_PDEATHSIG, donc fork + prctl dans l'enfant.
// macOS : ignoré pour l'instant (pas d'équivalent simple).
bool kill_with_parent = false;
};
// Processus lancé et supervisé (le Core en pratique) : handle de processus
// sous Windows, pid + waitpid sous POSIX (ADR 0006 : identification par handle,
// pas par le seul PID).
// Processus enfant lancé et supervisé (le Core pour le Studio host, le rendu
// pour le Core, ADR 0009) : handle de processus sous Windows, pid + waitpid
// sous POSIX (ADR 0006 : identification par handle, pas par le seul PID).
//
// LE CYCLE DE VIE APPARTIENT À L'APPELANT : le destructeur NE tue PAS le
// processus (§7.6 : l'appelant choisit d'arrêter, de détacher ou de laisser
// vivre). Non copiable, déplaçable.
class CoreProcess {
class ChildProcess {
public:
// Lance exe avec args (chaînes UTF-8 ; sous Windows chaque argument est cité
// à la manière compatible CommandLineToArgvW, espaces et guillemets
// compris). Échec (exécutable absent, générateur d'arguments invalide,
// API système en échec) -> nullopt, jamais d'exception.
[[nodiscard]] static std::optional<CoreProcess> launch(
// API système en échec, assignation au job object impossible ->
// l'enfant est terminé) -> nullopt, jamais d'exception.
[[nodiscard]] static std::optional<ChildProcess> launch(
std::filesystem::path const& exe,
std::vector<std::string> const& args,
LaunchOptions options = {});
// Ne tue PAS le processus ; libère seulement le handle.
~CoreProcess();
CoreProcess(CoreProcess&& other) noexcept;
CoreProcess& operator=(CoreProcess&& other) noexcept;
CoreProcess(CoreProcess const&) = delete;
CoreProcess& operator=(CoreProcess const&) = delete;
~ChildProcess();
ChildProcess(ChildProcess&& other) noexcept;
ChildProcess& operator=(ChildProcess&& other) noexcept;
ChildProcess(ChildProcess const&) = delete;
ChildProcess& operator=(ChildProcess const&) = delete;
[[nodiscard]] std::uint32_t pid() const noexcept;
@@ -74,7 +86,7 @@ public:
void terminate();
private:
CoreProcess() = default; // seulement via launch()
ChildProcess() = default; // seulement via launch()
// Sous POSIX : récolte le code de sortie en sondant pendant au plus
// grace_ms ; retourne true si le processus s'est révélé terminé.
@@ -85,4 +97,4 @@ private:
mutable std::optional<int> code_; // code de sortie connu (POSIX : waitpid)
};
} // namespace sequa::studio::host
} // namespace sequa::platform
@@ -1,7 +1,5 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/studio/host/process.hpp"
#include "sequa/studio/host/time.hpp"
#include "sequa/platform/process.hpp"
#include <string>
#include <thread>
@@ -16,24 +14,53 @@
#define WIN32_LEAN_AND_MEAN
#endif
#include <Windows.h>
#include <format>
#include <mutex>
#else
#include <cerrno>
#include <csignal>
#include <ctime>
#include <fcntl.h>
#include <spawn.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#if defined(__linux__)
#include <sys/prctl.h>
#endif
extern char** environ;
#endif
namespace sequa::studio::host {
namespace sequa::platform {
namespace {
// Horodatage ISO 8601 UTC pour l'en-tête de journal. La brique processus ne
// dépend pas du Studio host : réimplémentation locale, minimale.
std::string utc_now_iso8601()
{
#if defined(_WIN32)
SYSTEMTIME now{};
GetSystemTime(&now);
return std::format("{:04d}-{:02d}-{:02d}T{:02d}:{:02d}:{:02d}Z",
static_cast<int>(now.wYear), static_cast<int>(now.wMonth),
static_cast<int>(now.wDay), static_cast<int>(now.wHour),
static_cast<int>(now.wMinute), static_cast<int>(now.wSecond));
#else
const std::time_t now = std::time(nullptr);
std::tm tm{};
if (gmtime_r(&now, &tm) == nullptr) {
return {};
}
char buffer[32];
const std::size_t written = std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &tm);
return std::string(buffer, written);
#endif
}
std::string header_line()
{
return "\n===== " + utc_now_iso8601() + " : lancement par sequa-studio =====\n";
return "\n===== " + utc_now_iso8601() + " : lancement par SEQUA =====\n";
}
#if defined(_WIN32)
@@ -79,6 +106,34 @@ void append_quoted(std::wstring& command_line, const std::wstring& argument)
command_line += L'"';
}
// Job object unique du processus courant, créé au premier lancement demandant
// « mourir avec le parent » (std::call_once : plusieurs fils peuvent lancer
// simultanément). JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE tue tous les processus du
// job quand le DERNIER handle sur le job se ferme : comme ce handle n'est
// jamais refermé ni hérité par l'enfant, c'est la mort du parent (exécution
// normale OU brutale, qui détruit toute la table de handles) qui le referme.
// nullptr si la création a échoué : le lancement renverra nullopt.
HANDLE job_kill_on_close()
{
static std::once_flag once;
static HANDLE job = nullptr;
std::call_once(once, [] {
HANDLE created = CreateJobObjectW(nullptr, nullptr);
if (created == nullptr) {
return;
}
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (SetInformationJobObject(created, JobObjectExtendedLimitInformation, &limits,
sizeof(limits))) {
job = created;
} else {
CloseHandle(created);
}
});
return job;
}
#endif
} // namespace
@@ -105,8 +160,9 @@ bool is_process_alive(std::uint32_t pid)
return alive;
}
std::optional<CoreProcess> CoreProcess::launch(const std::filesystem::path& exe, const std::vector<std::string>& args,
LaunchOptions options)
std::optional<ChildProcess> ChildProcess::launch(const std::filesystem::path& exe,
const std::vector<std::string>& args,
LaunchOptions options)
{
std::error_code ec;
if (!std::filesystem::is_regular_file(exe, ec)) {
@@ -133,7 +189,8 @@ std::optional<CoreProcess> CoreProcess::launch(const std::filesystem::path& exe,
WriteFile(log, header.data(), static_cast<DWORD>(header.size()), &written, nullptr);
}
// Seul le handle du journal est hérité : jamais les autres handles du host.
// Seul le handle du journal est hérité : jamais les autres handles du parent
// (et notamment pas le handle du job object).
STARTUPINFOEXW startup{};
startup.StartupInfo.cb = sizeof(startup);
std::vector<unsigned char> attribute_storage;
@@ -158,6 +215,13 @@ std::optional<CoreProcess> CoreProcess::launch(const std::filesystem::path& exe,
inherit = TRUE;
}
// « Mourir avec le parent » : l'enfant est créé SUSPENDU, affecté au job
// AVANT de tourner, puis seulement relancé (sinon une fenêtre minuscule
// laisse l'enfant s'exécuter hors du job et survivre au parent).
if (options.kill_with_parent) {
flags |= CREATE_SUSPENDED;
}
PROCESS_INFORMATION info{};
const BOOL created = CreateProcessW(nullptr, command_line.data(), nullptr, nullptr, inherit, flags, nullptr,
nullptr, &startup.StartupInfo, &info);
@@ -170,27 +234,41 @@ std::optional<CoreProcess> CoreProcess::launch(const std::filesystem::path& exe,
if (!created) {
return std::nullopt;
}
// Affectation au job (le handle du fil est fermé dans tous les cas, échec ou
// succès) puis seulement reprise de l'enfant. Tout échec balaie l'enfant
// Suspendu, qui n'a exécuté aucun code : TerminateProcess puis nullopt.
if (options.kill_with_parent) {
HANDLE job = job_kill_on_close();
if (job == nullptr || !AssignProcessToJobObject(job, info.hProcess)
|| ResumeThread(info.hThread) == static_cast<DWORD>(-1)) {
(void)!TerminateProcess(info.hProcess, 1);
CloseHandle(info.hThread);
CloseHandle(info.hProcess);
return std::nullopt;
}
}
CloseHandle(info.hThread);
CoreProcess process;
ChildProcess process;
process.pid_ = static_cast<std::uint32_t>(info.dwProcessId);
process.handle_ = info.hProcess;
return process;
}
CoreProcess::~CoreProcess()
ChildProcess::~ChildProcess()
{
if (handle_ != nullptr) {
CloseHandle(static_cast<HANDLE>(handle_));
}
}
bool CoreProcess::running() const
bool ChildProcess::running() const
{
return handle_ != nullptr && WaitForSingleObject(static_cast<HANDLE>(handle_), 0) == WAIT_TIMEOUT;
}
std::optional<int> CoreProcess::wait_for(std::chrono::milliseconds timeout) const
std::optional<int> ChildProcess::wait_for(std::chrono::milliseconds timeout) const
{
if (code_) {
return code_;
@@ -210,7 +288,7 @@ std::optional<int> CoreProcess::wait_for(std::chrono::milliseconds timeout) cons
return code_;
}
void CoreProcess::terminate()
void ChildProcess::terminate()
{
if (handle_ != nullptr && running()) {
TerminateProcess(static_cast<HANDLE>(handle_), 1);
@@ -218,7 +296,7 @@ void CoreProcess::terminate()
}
}
bool CoreProcess::reap_deadline_ms(int /*grace_ms*/) const
bool ChildProcess::reap_deadline_ms(int /*grace_ms*/) const
{
return !running();
}
@@ -238,14 +316,89 @@ bool is_process_alive(std::uint32_t pid)
return ::kill(static_cast<pid_t>(pid), 0) == 0 || errno == EPERM;
}
std::optional<CoreProcess> CoreProcess::launch(const std::filesystem::path& exe, const std::vector<std::string>& args,
LaunchOptions options)
std::optional<ChildProcess> ChildProcess::launch(const std::filesystem::path& exe,
const std::vector<std::string>& args,
LaunchOptions options)
{
std::error_code ec;
if (!std::filesystem::is_regular_file(exe, ec)) {
return std::nullopt;
}
// Tout (argv, ouverts, chemins) est préparé AVANT fork : entre fork et exec,
// seules des fonctions async-signal-safe sont autorisées. La console propre
// (« own_console ») est ignorée sous POSIX, comme pour posix_spawn.
#if defined(__linux__)
// Linux : posix_spawn ne permet pas PR_SET_PDEATHSIG. Pour « mourir avec le
// parent », on passe par fork + prctl dans l'enfant (ADR 0009).
if (options.kill_with_parent) {
const pid_t parent = getpid();
// argv en chaînes câblées, stables pendant l'enfant.
std::vector<std::string> storage;
storage.reserve(args.size() + 1);
storage.push_back(exe.string());
storage.insert(storage.end(), args.begin(), args.end());
std::vector<char*> argv;
for (std::string& argument : storage) {
argv.push_back(argument.data());
}
argv.push_back(nullptr);
// Chemin de journal préconverti (l'open est fait dans l'enfant : les fd
// hérités seraient en plus à replier après l'échec de exec).
const std::string exe_path = storage.front();
const std::string log_path = options.log_path.string();
const bool redirect = !log_path.empty();
const pid_t pid = ::fork();
if (pid < 0) {
return std::nullopt;
}
if (pid == 0) {
// Enfant : async-signal-safe uniquement ci-dessous.
// La mort du parent (même SIGKILL, même TerminateProcess côté
// système d'exploitation) tue l'enfant avec SIGKILL.
::prctl(PR_SET_PDEATHSIG, static_cast<unsigned long>(SIGKILL));
// Si le parent est déjà mort entre fork et prctl, on ne reste pas
// orphelin : le re-parentage (ppid = 1) est l'indice.
if (::getppid() != parent) {
::_exit(127);
}
// Mêmes redirections que le chemin posix_spawn : /dev/null sur 0,
// journal en ajout sur 1 puis dupliqué sur 2.
const int nothing = ::open("/dev/null", O_RDONLY | O_CLOEXEC);
if (nothing < 0) {
::_exit(127);
}
if (::dup2(nothing, 0) < 0) {
::_exit(127);
}
::close(nothing);
if (redirect) {
const int log = ::open(log_path.c_str(), O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0600);
if (log < 0) {
::_exit(127);
}
if (::dup2(log, 1) < 0 || ::dup2(log, 2) < 0) {
::_exit(127);
}
::close(log);
}
::execv(exe_path.c_str(), argv.data());
::_exit(127); // execv ne rend que par l'échec
}
ChildProcess process;
process.pid_ = static_cast<std::uint32_t>(pid);
return process;
}
#else
(void)options.kill_with_parent; // macOS : ignoré, pas d'équivalent simple pour l'instant
#endif
posix_spawn_file_actions_t actions;
posix_spawn_file_actions_init(&actions);
if (!options.log_path.empty()) {
@@ -278,19 +431,19 @@ std::optional<CoreProcess> CoreProcess::launch(const std::filesystem::path& exe,
if (result != 0) {
return std::nullopt;
}
CoreProcess process;
ChildProcess process;
process.pid_ = static_cast<std::uint32_t>(pid);
return process;
}
CoreProcess::~CoreProcess() = default; // pas de handle ; le processus n'est pas tué
ChildProcess::~ChildProcess() = default; // pas de handle ; le processus n'est pas tué
bool CoreProcess::running() const
bool ChildProcess::running() const
{
return pid_ != 0 && !wait_for(std::chrono::milliseconds(0));
}
std::optional<int> CoreProcess::wait_for(std::chrono::milliseconds timeout) const
std::optional<int> ChildProcess::wait_for(std::chrono::milliseconds timeout) const
{
if (code_ || pid_ == 0) {
return code_;
@@ -313,12 +466,12 @@ std::optional<int> CoreProcess::wait_for(std::chrono::milliseconds timeout) cons
}
}
bool CoreProcess::reap_deadline_ms(int grace_ms) const
bool ChildProcess::reap_deadline_ms(int grace_ms) const
{
return wait_for(std::chrono::milliseconds(grace_ms)).has_value();
}
void CoreProcess::terminate()
void ChildProcess::terminate()
{
if (pid_ == 0 || code_) {
return;
@@ -332,16 +485,16 @@ void CoreProcess::terminate()
#endif
CoreProcess::CoreProcess(CoreProcess&& other) noexcept
ChildProcess::ChildProcess(ChildProcess&& other) noexcept
: pid_(std::exchange(other.pid_, 0)), handle_(std::exchange(other.handle_, nullptr)),
code_(std::exchange(other.code_, std::nullopt))
{
}
CoreProcess& CoreProcess::operator=(CoreProcess&& other) noexcept
ChildProcess& ChildProcess::operator=(ChildProcess&& other) noexcept
{
if (this != &other) {
this->~CoreProcess();
this->~ChildProcess();
pid_ = std::exchange(other.pid_, 0);
handle_ = std::exchange(other.handle_, nullptr);
code_ = std::exchange(other.code_, std::nullopt);
@@ -349,9 +502,9 @@ CoreProcess& CoreProcess::operator=(CoreProcess&& other) noexcept
return *this;
}
std::uint32_t CoreProcess::pid() const noexcept
std::uint32_t ChildProcess::pid() const noexcept
{
return pid_;
}
} // namespace sequa::studio::host
} // namespace sequa::platform
+3
View File
@@ -0,0 +1,3 @@
// SPDX-License-Identifier: MPL-2.0
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: MPL-2.0
// Dossier temporaire propre à un test, supprimé à la fin. Jamais le vrai dossier
// d'exécution de SEQUA.
#pragma once
#include "sequa/platform/process.hpp"
#include <filesystem>
#include <system_error>
namespace sequa::platform::testing {
class TempDir {
public:
TempDir()
: path_(std::filesystem::temp_directory_path()
/ ("sequa-process-" + std::to_string(current_process_id()) + "-"
+ std::to_string(++counter_)))
{
std::filesystem::create_directories(path_);
}
~TempDir()
{
std::error_code ignored;
std::filesystem::remove_all(path_, ignored);
}
TempDir(const TempDir&) = delete;
TempDir& operator=(const TempDir&) = delete;
[[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; }
private:
// Compteur de secours : deux dossiers créés dans la même milliseconde par le
// même processus ne se chevauchent pas.
inline static int counter_ = 0;
std::filesystem::path path_;
};
} // namespace sequa::platform::testing
@@ -1,11 +1,11 @@
// SPDX-License-Identifier: MPL-2.0
//
// Processus enfant des tests du superviseur. Arguments traités dans l'ordre :
// Processus enfant des tests de la brique processus. Arguments traités dans l'ordre :
// --out <texte> ligne sur stdout --err <texte> ligne sur stderr
// --echo-rest recopie chaque argument suivant entre crochets, un par ligne
// --sleep <ms> attend --exit <code> sort avec ce code
// Les arguments sont relus en UTF-16 sous Windows et écrits en UTF-8 : on vérifie ce que
// le superviseur a réellement transmis, pas la conversion de la page de code locale.
// le lanceur a réellement transmis, pas la conversion de la page de code locale.
#include <chrono>
#include <cstdio>
@@ -1,8 +1,5 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/studio/host/files.hpp"
#include "sequa/studio/host/paths.hpp"
#include "sequa/studio/host/process.hpp"
#include "sequa/studio/host/shadow.hpp"
#include "sequa/platform/process.hpp"
#include "temp_dir.hpp"
@@ -10,16 +7,32 @@
#include <algorithm>
#include <chrono>
#include <fstream>
#include <string>
#include <vector>
using namespace sequa::studio::host;
using namespace sequa::platform;
using namespace std::chrono_literals;
namespace {
const std::filesystem::path child = SEQUA_TEST_CHILD;
// Les journaux de test restent petits : lecture entière, lignes séparées
// (équivalent local à read_last_lines du supervisor, qui reste du côté Studio).
std::vector<std::string> read_lines(const std::filesystem::path& path)
{
std::ifstream file(path, std::ios::binary);
std::vector<std::string> lines;
for (std::string line; std::getline(file, line);) {
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
lines.push_back(std::move(line));
}
return lines;
}
bool contains(const std::vector<std::string>& lines, const std::string& expected)
{
return std::find(lines.begin(), lines.end(), expected) != lines.end();
@@ -30,21 +43,21 @@ bool contains(const std::vector<std::string>& lines, const std::string& expected
TEST_CASE("Processus : code de sortie et journal en ajout (stdout et stderr)")
{
testing::TempDir dir;
const auto log = log_file_path(dir.path(), 9100);
const auto log = dir.path() / "journal-9100.log";
LaunchOptions options;
options.log_path = log;
auto first = CoreProcess::launch(child, {"--out", "premier", "--err", "erreur", "--exit", "7"}, options);
auto first = ChildProcess::launch(child, {"--out", "premier", "--err", "erreur", "--exit", "7"}, options);
REQUIRE(first.has_value());
CHECK(first->pid() != 0);
CHECK(first->wait_for(5s) == 7);
CHECK_FALSE(first->running());
auto second = CoreProcess::launch(child, {"--out", "second"}, options);
auto second = ChildProcess::launch(child, {"--out", "second"}, options);
REQUIRE(second.has_value());
CHECK(second->wait_for(5s) == 0);
const auto lines = read_last_lines(log, 100);
const auto lines = read_lines(log);
CHECK(contains(lines, "premier"));
CHECK(contains(lines, "erreur"));
CHECK(contains(lines, "second")); // le premier lancement n'a pas été écrasé
@@ -63,10 +76,10 @@ TEST_CASE("Processus : arguments avec espaces, guillemets et antislashs")
"", "accentué é"};
std::vector<std::string> args = {"--echo-rest"};
args.insert(args.end(), tricky.begin(), tricky.end());
auto process = CoreProcess::launch(child, args, options);
auto process = ChildProcess::launch(child, args, options);
REQUIRE(process.has_value());
REQUIRE(process->wait_for(5s) == 0);
const auto lines = read_last_lines(options.log_path, 100);
const auto lines = read_lines(options.log_path);
for (const std::string& argument : tricky) {
CAPTURE(argument);
CHECK(contains(lines, "[" + argument + "]"));
@@ -75,7 +88,7 @@ TEST_CASE("Processus : arguments avec espaces, guillemets et antislashs")
TEST_CASE("Processus : wait_for avec delai, terminate, is_process_alive")
{
auto process = CoreProcess::launch(child, {"--sleep", "10000"});
auto process = ChildProcess::launch(child, {"--sleep", "10000"});
REQUIRE(process.has_value());
CHECK(process->running());
CHECK(is_process_alive(process->pid()));
@@ -89,10 +102,10 @@ TEST_CASE("Processus : wait_for avec delai, terminate, is_process_alive")
TEST_CASE("Processus : executable absent -> nullopt ; deplacement")
{
CHECK_FALSE(CoreProcess::launch("introuvable-sequa.exe", {}).has_value());
auto process = CoreProcess::launch(child, {"--exit", "3"});
CHECK_FALSE(ChildProcess::launch("introuvable-sequa.exe", {}).has_value());
auto process = ChildProcess::launch(child, {"--exit", "3"});
REQUIRE(process.has_value());
CoreProcess moved = std::move(*process);
ChildProcess moved = std::move(*process);
CHECK(moved.wait_for(5s) == 3);
}
@@ -100,52 +113,28 @@ TEST_CASE("Processus : le destructeur ne tue pas le processus")
{
std::uint32_t pid = 0;
{
auto process = CoreProcess::launch(child, {"--sleep", "1500"});
auto process = ChildProcess::launch(child, {"--sleep", "1500"});
REQUIRE(process.has_value());
pid = process->pid();
}
CHECK(is_process_alive(pid));
}
TEST_CASE("Copie fantome : nom, nettoyage, copie en cours d'execution conservee")
TEST_CASE("Processus : kill_with_parent (job object sous Windows, fork + PR_SET_PDEATHSIG sous Linux)")
{
testing::TempDir dir;
const auto bin = bin_dir(dir.path());
const auto first = make_shadow_copy(child, bin);
const auto second = make_shadow_copy(child, bin); // même seconde : suffixe -1
REQUIRE(first.has_value());
REQUIRE(second.has_value());
CHECK(*first != *second);
CHECK(is_shadow_copy_name(first->filename().string()));
CHECK(is_shadow_copy_name(second->filename().string()));
CHECK_FALSE(make_shadow_copy(dir.path() / "absent.exe", bin).has_value());
// Un fichier étranger dans bin/ n'est jamais touché.
const auto foreign = bin / "autre-outil.exe";
std::filesystem::copy_file(child, foreign);
auto running = CoreProcess::launch(*first, {"--sleep", "3000"});
REQUIRE(running.has_value());
const std::size_t removed = cleanup_shadow_copies(bin);
#if defined(_WIN32)
CHECK(removed == 1); // la copie en cours d'exécution est verrouillée
CHECK(std::filesystem::exists(*first));
#else
CHECK(removed == 2);
#endif
CHECK_FALSE(std::filesystem::exists(*second));
CHECK(std::filesystem::exists(foreign));
running->terminate();
CHECK(cleanup_shadow_copies(bin) <= 1);
}
TEST_CASE("Copie fantome : reconnaissance du motif")
{
CHECK(is_shadow_copy_name("sequa-core-20261106T210000Z-1234.exe"));
CHECK(is_shadow_copy_name("sequa-core-20261106T210000Z-1234-2"));
CHECK_FALSE(is_shadow_copy_name("sequa-core.exe"));
CHECK_FALSE(is_shadow_copy_name("sequa-core-20261106T210000Z.exe"));
CHECK_FALSE(is_shadow_copy_name("sequa-core-2026110XT210000Z-1.exe"));
CHECK_FALSE(is_shadow_copy_name("sequa-core-20261106T210000Z-12a.exe"));
CHECK_FALSE(is_shadow_copy_name("autre-20261106T210000Z-1.exe"));
// Dans le même processus de test, on ne peut pas observer la mort du parent
// (c'est ce processus qui porte le test). On vérifie donc ici que le
// lancement avec l'option réussit, que l'enfant tourne et reste supervisable
// normalement, et que terminate() rend la sortie comme sans l'option ; la
// mort du parent (job object refermé par la disparition de ses handles,
// SIGKILL de PR_SET_PDEATHSIG) est le mécanisme système lui-même, à
// confirmer en dehors du test unitaire (ADR 0009).
LaunchOptions options;
options.kill_with_parent = true;
auto process = ChildProcess::launch(child, {"--sleep", "10000"}, options);
REQUIRE(process.has_value());
CHECK(process->running());
process->terminate();
CHECK(process->wait_for(2s).has_value());
CHECK_FALSE(process->running());
}
+1 -1
View File
@@ -140,7 +140,7 @@ std::string StudioHost::launch_core()
LaunchOptions launch;
launch.own_console = config_.core_console;
launch.log_path = log_file_path(config_.run_dir, config_.core_port);
process_ = CoreProcess::launch(*copy, args, launch);
process_ = sequa::platform::ChildProcess::launch(*copy, args, launch);
if (!process_) {
return "Lancement du Core impossible : " + copy->string();
}
+9 -2
View File
@@ -2,9 +2,10 @@
#pragma once
#include "sequa/studio/host/client.hpp"
#include "sequa/studio/host/process.hpp"
#include "sequa/studio/host/run_file.hpp"
#include "sequa/platform/process.hpp"
#include <nlohmann/json.hpp>
#include <chrono>
@@ -19,6 +20,12 @@
namespace sequa::studio::host {
// Les utilitaires et lancement de processus vivent désormais dans la brique
// commune sequa::platform::process (ADR 0009) ; ces alias gardent les usages du
// Studio host (app, tests) fonctionnels.
using sequa::platform::is_process_alive;
using sequa::platform::LaunchOptions;
struct HostConfig {
std::filesystem::path core_exe; // exécutable SOURCE du Core (jamais lancé directement)
std::filesystem::path run_dir; // dossier d'exécution (§7.4)
@@ -89,7 +96,7 @@ private:
HostConfig config_;
std::unique_ptr<CoreClient> client_;
std::optional<CoreProcess> process_; // vide si le Core a été rejoint (lancé par un autre Studio)
std::optional<sequa::platform::ChildProcess> process_; // vide si le Core a été rejoint (lancé par un autre Studio)
RunFile run_;
bool attached_existing_ = false;
bool linked_ = true;
+12 -16
View File
@@ -1,13 +1,13 @@
# SPDX-License-Identifier: MPL-2.0
#
# Brique supervisor du Studio host : lancement du Core depuis une copie fantôme, handle de
# processus, fichier d'exécution, journaux, surveillance de l'exécutable source
# (docs/specs/protocole-studio-core-0.2.md §7.2 à §7.6).
# Brique supervisor du Studio host : lancement du Core depuis une copie fantôme,
# fichier d'exécution, journaux, surveillance de l'exécutable source. Le
# lancement en lui-même est passé dans la brique commune platform/process
# (le Core lance aussi des processus : ADR 0009), que cette brique lie.
add_library(sequa_host_supervisor
src/files.cpp
src/paths.cpp
src/process.cpp
src/random.cpp
src/run_file.cpp
src/shadow.cpp
@@ -15,29 +15,25 @@ add_library(sequa_host_supervisor
add_library(sequa::host_supervisor ALIAS sequa_host_supervisor)
target_include_directories(sequa_host_supervisor PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include")
target_compile_features(sequa_host_supervisor PUBLIC cxx_std_20)
target_link_libraries(sequa_host_supervisor PRIVATE nlohmann_json::nlohmann_json)
target_link_libraries(sequa_host_supervisor
PUBLIC sequa::process
PRIVATE nlohmann_json::nlohmann_json)
if(WIN32)
target_link_libraries(sequa_host_supervisor PRIVATE bcrypt)
endif()
sequa_target_defaults(sequa_host_supervisor)
if(SEQUA_BUILD_TESTS)
# Processus enfant de test : écrit, attend, sort avec un code donné.
add_executable(sequa_host_test_child tests/test_child.cpp)
if(WIN32)
target_link_libraries(sequa_host_test_child PRIVATE shell32)
endif()
sequa_target_defaults(sequa_host_test_child)
add_executable(sequa_host_supervisor_tests
tests/main.cpp
tests/test_files.cpp
tests/test_process.cpp
tests/test_run_file.cpp)
tests/test_run_file.cpp
tests/test_shadow.cpp)
target_link_libraries(sequa_host_supervisor_tests PRIVATE sequa::host_supervisor doctest::doctest)
# Enfant de test fourni par platform/process (ajouté avant studio/host).
target_compile_definitions(sequa_host_supervisor_tests PRIVATE
SEQUA_TEST_CHILD="$<TARGET_FILE:sequa_host_test_child>")
add_dependencies(sequa_host_supervisor_tests sequa_host_test_child)
SEQUA_TEST_CHILD="$<TARGET_FILE:sequa_process_test_child>")
add_dependencies(sequa_host_supervisor_tests sequa_process_test_child)
sequa_target_defaults(sequa_host_supervisor_tests)
doctest_discover_tests(sequa_host_supervisor_tests)
endif()
+2 -2
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/studio/host/shadow.hpp"
#include "sequa/studio/host/process.hpp"
#include "sequa/platform/process.hpp"
#include "sequa/studio/host/time.hpp"
#include <cctype>
@@ -75,7 +75,7 @@ std::optional<std::filesystem::path> make_shadow_copy(const std::filesystem::pat
}
const std::string base =
std::string(prefix) + utc_now_compact() + "-" + std::to_string(current_process_id());
std::string(prefix) + utc_now_compact() + "-" + std::to_string(sequa::platform::current_process_id());
for (int attempt = 0; attempt < 100; ++attempt) {
const std::string name =
base + (attempt == 0 ? std::string() : "-" + std::to_string(attempt)) + std::string(executable_suffix);
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/platform/process.hpp"
#include "sequa/studio/host/paths.hpp"
#include "sequa/studio/host/shadow.hpp"
#include "temp_dir.hpp"
#include <doctest/doctest.h>
#include <filesystem>
using namespace sequa::studio::host;
namespace {
// Enfant de test de platform/process : écrit, attend, sort avec un code donné.
const std::filesystem::path child = SEQUA_TEST_CHILD;
} // namespace
TEST_CASE("Copie fantome : nom, nettoyage, copie en cours d'execution conservee")
{
testing::TempDir dir;
const auto bin = bin_dir(dir.path());
const auto first = make_shadow_copy(child, bin);
const auto second = make_shadow_copy(child, bin); // même seconde : suffixe -1
REQUIRE(first.has_value());
REQUIRE(second.has_value());
CHECK(*first != *second);
CHECK(is_shadow_copy_name(first->filename().string()));
CHECK(is_shadow_copy_name(second->filename().string()));
CHECK_FALSE(make_shadow_copy(dir.path() / "absent.exe", bin).has_value());
// Un fichier étranger dans bin/ n'est jamais touché.
const auto foreign = bin / "autre-outil.exe";
std::filesystem::copy_file(child, foreign);
auto running = sequa::platform::ChildProcess::launch(*first, {"--sleep", "3000"});
REQUIRE(running.has_value());
const std::size_t removed = cleanup_shadow_copies(bin);
#if defined(_WIN32)
CHECK(removed == 1); // la copie en cours d'exécution est verrouillée
CHECK(std::filesystem::exists(*first));
#else
CHECK(removed == 2);
#endif
CHECK_FALSE(std::filesystem::exists(*second));
CHECK(std::filesystem::exists(foreign));
running->terminate();
CHECK(cleanup_shadow_copies(bin) <= 1);
}
TEST_CASE("Copie fantome : reconnaissance du motif")
{
CHECK(is_shadow_copy_name("sequa-core-20261106T210000Z-1234.exe"));
CHECK(is_shadow_copy_name("sequa-core-20261106T210000Z-1234-2"));
CHECK_FALSE(is_shadow_copy_name("sequa-core.exe"));
CHECK_FALSE(is_shadow_copy_name("sequa-core-20261106T210000Z.exe"));
CHECK_FALSE(is_shadow_copy_name("sequa-core-2026110XT210000Z-1.exe"));
CHECK_FALSE(is_shadow_copy_name("sequa-core-20261106T210000Z-12a.exe"));
CHECK_FALSE(is_shadow_copy_name("autre-20261106T210000Z-1.exe"));
}