feat(studio/host): brique supervisor, cycle de vie du Core

En-têtes repris d'un subagent interrompu, implémentation et tests écrits ensuite : CoreProcess (CreateProcessW avec héritage limité au journal, citation compatible CommandLineToArgvW, journal en ajout ; posix_spawn sous POSIX, non testé), is_process_alive, fichier d'exécution atomique, copies fantômes et nettoyage, read_last_lines, FileWatcher. 15 cas (Debug, ASan) avec un processus enfant de test.
This commit is contained in:
Ange-Marie MAURIN - A2M Systèmes committed 2026-11-06 23:06:34 +01:00
1 parent a185b3414a
commit 832bd52fc9
22 files changed
+1782

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
# 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).
add_library(sequa_host_supervisor
src/files.cpp
src/paths.cpp
src/process.cpp
src/random.cpp
src/run_file.cpp
src/shadow.cpp
src/time.cpp)
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)
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)
target_link_libraries(sequa_host_supervisor_tests PRIVATE sequa::host_supervisor doctest::doctest)
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_target_defaults(sequa_host_supervisor_tests)
doctest_discover_tests(sequa_host_supervisor_tests)
endif()
+22
View File
@@ -0,0 +1,22 @@
# Studio host : supervisor
Brique du Studio host (`sequa-studio`) qui fait vivre le Core comme un processus séparé : SEQUA Studio dirige un monde au-dessus d'un Core déterministe ([README de Studio](../../README.md)), et cette brique lance ce Core, le suit et le relance ([ADR 0006](../../../docs/adr/0006-studio-host-et-cycle-de-vie-du-core.md), [spécification §7](../../../docs/specs/protocole-studio-core-0.2.md)).
## API (`sequa::studio::host`)
| En-tête | Rôle |
|---|---|
| `paths.hpp` | Dossier d'exécution par plateforme, `bin/`, `core-<port>.json`, `core-<port>.log` |
| `random.hpp` | `random_hex` cryptographique (jetons) |
| `time.hpp` | Horodatages UTC (ISO 8601, compact) |
| `run_file.hpp` | Fichier d'exécution : lecture tolérante, écriture atomique, droits restreints (POSIX) |
| `shadow.hpp` | Copie fantôme de l'exécutable du Core, nettoyage des copies inutilisées |
| `process.hpp` | `CoreProcess` : lancement (arguments cités correctement, journal en ajout, seul le handle du journal est hérité sous Windows), `running`, `wait_for`, `terminate` ; le destructeur ne tue pas le processus |
| `files.hpp` | `read_last_lines` (lecture bornée par la fin), `FileWatcher` (changement signalé après stabilité) |
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.
- Dépendance : nlohmann/json 3.12.0 (MIT), privée.
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
namespace sequa::studio::host {
// Empreinte d'un fichier : existence, taille, date de modification.
struct FileStamp {
bool exists = false;
std::uintmax_t size = 0;
std::int64_t modified_ns = 0; // nanosecondes depuis l'epoch (horloge système)
[[nodiscard]] friend bool operator==(FileStamp const&, FileStamp const&) = default;
};
// Empreinte d'un fichier (fichier absent -> exists = false, reste à zéro).
// Jamais d'exception, même si le fichier disparaît pendant la lecture.
[[nodiscard]] FileStamp stamp(std::filesystem::path const& path) noexcept;
// N dernières lignes d'un fichier (commande `log` §7.3). Fichier absent, vide
// ou illisible -> vecteur vide. Les \r finaux sont retirés. La lecture est
// bornée (au plus 1 Mio depuis la fin) : un journal qui grossit ne coûte pas
// un chargement complet. Jamais d'exception.
[[nodiscard]] std::vector<std::string>
read_last_lines(std::filesystem::path const& path, std::size_t max_lines);
// Surveille un fichier et signale un changement une fois qu'il est RESTÉ STABLE
// pendant stable_delay (1 s par défaut : un exécutable en cours d'écriture par
// le build n'est pas signalé, §7.6). Interrogé par l'appelant via poll() :
// AUCUN fil interne.
class FileWatcher {
public:
explicit FileWatcher(std::filesystem::path path,
std::chrono::milliseconds stable_delay = std::chrono::milliseconds(1000));
// true exactement UNE fois par changement, au premier poll après
// stable_delay sans modification observée. false sinon (changé mais pas
// encore stable, ou rien de nouveau).
[[nodiscard]] bool poll();
[[nodiscard]] std::chrono::milliseconds stable_delay() const noexcept { return stable_delay_; }
private:
std::filesystem::path path_;
std::chrono::milliseconds stable_delay_;
FileStamp last_stamp_;
std::optional<std::chrono::steady_clock::time_point> last_change_;
};
} // namespace sequa::studio::host
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <cstdint>
#include <filesystem>
#include <optional>
namespace sequa::studio::host {
// Racine des données d'exécution de la plateforme (spécification §7.4) :
// Windows : %LOCALAPPDATA%\SEQUA\run
// Linux : $XDG_RUNTIME_DIR/sequa, sinon ~/.local/state/sequa/run
// macOS : ~/Library/Application Support/SEQUA/run
// Ne crée rien, n'écrit jamais dedans : nullopt si la variable d'environnement
// nécessaire manque. Les tests passent TOUJOURS une racine explicite et ne
// touchent jamais au vrai répertoire d'exécution.
[[nodiscard]] std::optional<std::filesystem::path> default_run_dir();
// Sous-dossier des copies fantômes : <run_dir>/bin (§7.5).
[[nodiscard]] std::filesystem::path bin_dir(std::filesystem::path const& run_dir);
// Fichier d'exécution du Core d'un port : <run_dir>/core-<port>.json (§7.4).
[[nodiscard]] std::filesystem::path run_file_path(std::filesystem::path const& run_dir, std::uint16_t port);
// Journal du Core d'un port : <run_dir>/core-<port>.log (§7.5).
[[nodiscard]] std::filesystem::path log_file_path(std::filesystem::path const& run_dir, std::uint16_t port);
// Crée <run_dir> et <run_dir>/bin (parents compris). Échec -> false,
// jamais d'exception.
[[nodiscard]] bool ensure_run_dirs(std::filesystem::path const& run_dir);
} // namespace sequa::studio::host
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
namespace sequa::studio::host {
// PID du processus courant (utilisé pour nommer les copies fantômes, §7.5).
[[nodiscard]] std::uint32_t current_process_id();
// Un PID désigne-t-il un processus encore vivant ? Pré-validation d'un fichier
// d'exécution (ADR 0006 : jamais le PID seul, la poignée de main qui doit
// renvoyer le même instanceId reste l'arbitre). pid 0 -> false. Sous POSIX,
// 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).
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.
// Le fichier est créé si absent ; son dossier doit exister.
std::filesystem::path log_path;
// true : le processus reste dans SA PROPRE console (CREATE_NEW_CONSOLE
// sous Windows), sans redirection de sortie (log_path ignoré, --core-console
// §7.1). Ignoré sous POSIX (pas de notion de console). false : aucune
// fenêtre (CREATE_NO_WINDOW sous Windows).
bool own_console = 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).
//
// 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 {
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(
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;
[[nodiscard]] std::uint32_t pid() const noexcept;
// Toujours en vie ? (jamais lancé, terminé ou rendu -> false)
[[nodiscard]] bool running() const;
// Attend la fin AU PLUS timeout. Terminé -> code de sortie (terminaison par
// signal : 128 + numéro sous POSIX ; terminaison forcée : 1 sous Windows).
// Encore en cours au bout du délai -> nullopt. Le code, une fois rendu, est
// mémorisé (les appels suivants ne ré-attendent pas).
[[nodiscard]] std::optional<int> wait_for(std::chrono::milliseconds timeout) const;
// Tue le processus : TerminateProcess(handle, 1) sous Windows ; SIGTERM puis
// SIGKILL après une grâce de 1 s sous POSIX. Ne lève jamais d'exception.
void terminate();
private:
CoreProcess() = 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é.
[[nodiscard]] bool reap_deadline_ms(int grace_ms) const;
std::uint32_t pid_ = 0;
void* handle_ = nullptr; // HANDLE de processus sous Windows
mutable std::optional<int> code_; // code de sortie connu (POSIX : waitpid)
};
} // namespace sequa::studio::host
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <cstddef>
#include <optional>
#include <string>
namespace sequa::studio::host {
// Chaîne hexadécimale minuscule de 2*bytes caractères, tirée d'un générateur
// cryptographique (BCryptGenRandom sous Windows, getrandom//dev/urandom sous
// POSIX). Sert pour les jetons et identifiants (§3, §7.2). bytes = 0 -> chaîne
// vide ; échec du générateur -> nullopt. Jamais d'exception.
[[nodiscard]] std::optional<std::string> random_hex(std::size_t bytes);
} // namespace sequa::studio::host
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
namespace sequa::studio::host {
// Fichier d'exécution du Core (§7.4) : tout ce qu'il faut pour rejoindre un Core
// déjà lancé par un autre host (ADR 0006 : jamais le PID seul comme identité).
struct RunFile {
std::uint32_t pid = 0; // PID du processus Core
std::string host = "127.0.0.1"; // adresse d'écoute du Core
std::uint16_t port = 9100; // port WebSocket du Core
std::string token; // jeton de connexion (32 hex)
std::string instanceId; // identifiant de CETTE exécution (32 hex)
std::filesystem::path exePath; // copie fantôme en cours d'exécution
std::filesystem::path sourceExePath; // exécutable source d'origine
std::string startedAt; // ISO 8601 UTC, ex. "2026-11-06T21:00:00Z"
[[nodiscard]] friend bool operator==(RunFile const&, RunFile const&) = default;
};
// Lecture tolérante : fichier absent, vide, illisible, JSON invalide, champ
// manquant ou invalide -> nullopt, jamais d'exception. Le fichier est alors
// considéré périmé (§7.2) ; les champs inconnus sont ignorés.
[[nodiscard]] std::optional<RunFile> read_run_file(std::filesystem::path const& path);
// Écriture ATOMIQUE : d'abord un fichier temporaire dans le même dossier, puis
// renommage. Le dossier parent doit exister (ensure_run_dirs). Le fichier
// contient le jeton (secret) : droits restreints à l'utilisateur sous POSIX
// (0600 dès la création). Échec -> false, jamais d'exception.
[[nodiscard]] bool write_run_file(std::filesystem::path const& path, RunFile const& run);
// Suppression ; fichier déjà absent -> true (succès). Échec -> false.
[[nodiscard]] bool remove_run_file(std::filesystem::path const& path);
} // namespace sequa::studio::host
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <cstddef>
#include <filesystem>
#include <optional>
#include <string>
namespace sequa::studio::host {
// Copie fantôme de l'exécutable source dans bin_dir (§7.5) :
// <bin_dir>/sequa-core-<AAAAMMJJTHHMMSSZ>-<pid du host>[.exe]
// pour que le build puisse remplacer l'exécutable source pendant que le Core
// tourne depuis sa copie. bin_dir est créé si nécessaire ; un suffixe -<n> est
// ajouté si le nom existe déjà (deux copies dans la même seconde). Échec ->
// nullopt, jamais d'exception.
[[nodiscard]] std::optional<std::filesystem::path>
make_shadow_copy(std::filesystem::path const& source, std::filesystem::path const& bin_dir);
// Supprime de bin_dir les copies fantômes DONT LA SUPPRESSION RÉUSSIT : sous
// Windows une copie en cours d'exécution est verrouillée (échec ignoré, la
// copie est encore utilisée §7.5), sous POSIX la suppression réussit de toute
// façon. Ne touche qu'aux fichiers suivant le motif des copies fantômes ;
// retourne le nombre de copies supprimées. Dossier absent -> 0.
[[nodiscard]] std::size_t cleanup_shadow_copies(std::filesystem::path const& bin_dir);
// Le nom de fichier suit-il le motif des copies fantômes :
// sequa-core-<AAAAMMJJTHHMMSSZ>-<pid>[-<n>][.exe] ?
[[nodiscard]] bool is_shadow_copy_name(std::string const& filename);
} // namespace sequa::studio::host
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <string>
namespace sequa::studio::host {
// Horodatage UTC actuel au format ISO 8601, seconde près :
// "2026-11-06T21:00:00Z"
// (champ startedAt du fichier d'exécution §7.4 ; en-tête des journaux §7.5).
[[nodiscard]] std::string utc_now_iso8601();
// Horodatage UTC actuel au format compact AAAAMMJJTHHMMSSZ :
// "20261106T210000Z"
// (nom des copies fantômes §7.5).
[[nodiscard]] std::string utc_now_compact();
} // namespace sequa::studio::host
+96
View File
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/studio/host/files.hpp"
#include <algorithm>
#include <fstream>
#include <system_error>
#include <utility>
namespace sequa::studio::host {
FileStamp stamp(const std::filesystem::path& path) noexcept
{
FileStamp result;
std::error_code ec;
const auto size = std::filesystem::file_size(path, ec);
if (ec) {
return result;
}
const auto modified = std::filesystem::last_write_time(path, ec);
if (ec) {
return result;
}
result.exists = true;
result.size = size;
result.modified_ns =
std::chrono::duration_cast<std::chrono::nanoseconds>(modified.time_since_epoch()).count();
return result;
}
std::vector<std::string> read_last_lines(const std::filesystem::path& path, std::size_t max_lines)
{
constexpr std::streamoff window = 1024 * 1024;
std::vector<std::string> lines;
if (max_lines == 0) {
return lines;
}
std::ifstream file(path, std::ios::binary);
if (!file) {
return lines;
}
file.seekg(0, std::ios::end);
const std::streamoff size = file.tellg();
if (size <= 0) {
return lines;
}
const std::streamoff start = size > window ? size - window : 0;
file.seekg(start);
std::string content(static_cast<std::size_t>(size - start), '\0');
file.read(content.data(), static_cast<std::streamsize>(content.size()));
content.resize(static_cast<std::size_t>(file.gcount()));
std::size_t begin = 0;
if (start > 0) {
const std::size_t first_newline = content.find('\n');
begin = first_newline == std::string::npos ? content.size() : first_newline + 1; // ligne coupée
}
while (begin < content.size()) {
std::size_t end = content.find('\n', begin);
if (end == std::string::npos) {
end = content.size();
}
std::string line = content.substr(begin, end - begin);
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
lines.push_back(std::move(line));
begin = end + 1;
}
if (lines.size() > max_lines) {
lines.erase(lines.begin(), lines.end() - static_cast<std::ptrdiff_t>(max_lines));
}
return lines;
}
FileWatcher::FileWatcher(std::filesystem::path path, std::chrono::milliseconds stable_delay)
: path_(std::move(path)), stable_delay_(stable_delay), last_stamp_(stamp(path_))
{
}
bool FileWatcher::poll()
{
const FileStamp current = stamp(path_);
const auto now = std::chrono::steady_clock::now();
if (current != last_stamp_) {
last_stamp_ = current;
last_change_ = now; // le compteur de stabilité repart de zéro
return false;
}
if (last_change_ && now - *last_change_ >= stable_delay_) {
last_change_.reset();
return current.exists; // un fichier supprimé n'est pas « nouveau »
}
return false;
}
} // namespace sequa::studio::host
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/studio/host/paths.hpp"
#include <cstdlib>
#include <filesystem>
#include <optional>
#include <string>
#include <system_error>
namespace sequa::studio::host {
namespace {
#if defined(_WIN32)
// %LOCALAPPDATA% peut contenir des caractères non ANSI : lecture en grand.
std::optional<std::filesystem::path> env_path(wchar_t const* name)
{
wchar_t* value = nullptr;
std::size_t length = 0;
if (_wdupenv_s(&value, &length, name) != 0 || value == nullptr) {
return std::nullopt;
}
std::optional<std::filesystem::path> result;
if (*value != L'\0') {
result = std::filesystem::path(value);
}
std::free(value);
return result;
}
#else
std::optional<std::filesystem::path> env_path(char const* name)
{
char const* value = std::getenv(name);
if (value == nullptr || *value == '\0') {
return std::nullopt;
}
return std::filesystem::path(value);
}
#endif
} // namespace
std::optional<std::filesystem::path> default_run_dir()
{
#if defined(_WIN32)
if (auto const base = env_path(L"LOCALAPPDATA")) {
return *base / "SEQUA" / "run";
}
return std::nullopt;
#elif defined(__APPLE__)
if (auto const home = env_path("HOME")) {
return *home / "Library" / "Application Support" / "SEQUA" / "run";
}
return std::nullopt;
#else
if (auto const xdg = env_path("XDG_RUNTIME_DIR")) {
return *xdg / "sequa";
}
if (auto const home = env_path("HOME")) {
return *home / ".local" / "state" / "sequa" / "run";
}
return std::nullopt;
#endif
}
std::filesystem::path bin_dir(std::filesystem::path const& run_dir)
{
return run_dir / "bin";
}
std::filesystem::path run_file_path(std::filesystem::path const& run_dir, std::uint16_t port)
{
return run_dir / ("core-" + std::to_string(port) + ".json");
}
std::filesystem::path log_file_path(std::filesystem::path const& run_dir, std::uint16_t port)
{
return run_dir / ("core-" + std::to_string(port) + ".log");
}
bool ensure_run_dirs(std::filesystem::path const& run_dir)
{
std::error_code ec;
std::filesystem::create_directories(bin_dir(run_dir), ec);
return !ec;
}
} // namespace sequa::studio::host
+357
View File
@@ -0,0 +1,357 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/studio/host/process.hpp"
#include "sequa/studio/host/time.hpp"
#include <string>
#include <thread>
#include <utility>
#include <vector>
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <Windows.h>
#else
#include <cerrno>
#include <csignal>
#include <fcntl.h>
#include <spawn.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
extern char** environ;
#endif
namespace sequa::studio::host {
namespace {
std::string header_line()
{
return "\n===== " + utc_now_iso8601() + " : lancement par sequa-studio =====\n";
}
#if defined(_WIN32)
std::wstring widen(std::string_view text)
{
if (text.empty()) {
return {};
}
const int size = MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), nullptr, 0);
std::wstring wide(static_cast<std::size_t>(size), L'\0');
MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), wide.data(), size);
return wide;
}
// Citation compatible avec CommandLineToArgvW / le runtime C de MSVC : les
// antislashs ne sont doublés que lorsqu'ils précèdent un guillemet.
void append_quoted(std::wstring& command_line, const std::wstring& argument)
{
if (!command_line.empty()) {
command_line += L' ';
}
if (!argument.empty() && argument.find_first_of(L" \t\n\v\"") == std::wstring::npos) {
command_line += argument;
return;
}
command_line += L'"';
std::size_t backslashes = 0;
for (const wchar_t c : argument) {
if (c == L'\\') {
++backslashes;
continue;
}
if (c == L'"') {
command_line.append(backslashes * 2 + 1, L'\\');
} else {
command_line.append(backslashes, L'\\');
}
backslashes = 0;
command_line += c;
}
command_line.append(backslashes * 2, L'\\');
command_line += L'"';
}
#endif
} // namespace
#if defined(_WIN32)
std::uint32_t current_process_id()
{
return static_cast<std::uint32_t>(GetCurrentProcessId());
}
bool is_process_alive(std::uint32_t pid)
{
if (pid == 0) {
return false;
}
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (process == nullptr) {
return GetLastError() == ERROR_ACCESS_DENIED; // existe, mais pas à nous
}
DWORD code = 0;
const bool alive = GetExitCodeProcess(process, &code) != 0 && code == STILL_ACTIVE;
CloseHandle(process);
return alive;
}
std::optional<CoreProcess> CoreProcess::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;
}
std::wstring command_line;
append_quoted(command_line, exe.wstring());
for (const std::string& argument : args) {
append_quoted(command_line, widen(argument));
}
HANDLE log = INVALID_HANDLE_VALUE;
const bool redirect = !options.own_console && !options.log_path.empty();
if (redirect) {
SECURITY_ATTRIBUTES inheritable{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
log = CreateFileW(options.log_path.c_str(), FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
&inheritable, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (log == INVALID_HANDLE_VALUE) {
return std::nullopt;
}
const std::string header = header_line();
DWORD written = 0;
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.
STARTUPINFOEXW startup{};
startup.StartupInfo.cb = sizeof(startup);
std::vector<unsigned char> attribute_storage;
DWORD flags = options.own_console ? CREATE_NEW_CONSOLE : CREATE_NO_WINDOW;
BOOL inherit = FALSE;
if (redirect) {
SIZE_T size = 0;
InitializeProcThreadAttributeList(nullptr, 1, 0, &size);
attribute_storage.resize(size);
startup.lpAttributeList = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(attribute_storage.data());
if (!InitializeProcThreadAttributeList(startup.lpAttributeList, 1, 0, &size) ||
!UpdateProcThreadAttribute(startup.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &log,
sizeof(HANDLE), nullptr, nullptr)) {
CloseHandle(log);
return std::nullopt;
}
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startup.StartupInfo.hStdInput = nullptr;
startup.StartupInfo.hStdOutput = log;
startup.StartupInfo.hStdError = log;
flags |= EXTENDED_STARTUPINFO_PRESENT;
inherit = TRUE;
}
PROCESS_INFORMATION info{};
const BOOL created = CreateProcessW(nullptr, command_line.data(), nullptr, nullptr, inherit, flags, nullptr,
nullptr, &startup.StartupInfo, &info);
if (startup.lpAttributeList != nullptr) {
DeleteProcThreadAttributeList(startup.lpAttributeList);
}
if (log != INVALID_HANDLE_VALUE) {
CloseHandle(log); // l'enfant a sa propre copie
}
if (!created) {
return std::nullopt;
}
CloseHandle(info.hThread);
CoreProcess process;
process.pid_ = static_cast<std::uint32_t>(info.dwProcessId);
process.handle_ = info.hProcess;
return process;
}
CoreProcess::~CoreProcess()
{
if (handle_ != nullptr) {
CloseHandle(static_cast<HANDLE>(handle_));
}
}
bool CoreProcess::running() const
{
return handle_ != nullptr && WaitForSingleObject(static_cast<HANDLE>(handle_), 0) == WAIT_TIMEOUT;
}
std::optional<int> CoreProcess::wait_for(std::chrono::milliseconds timeout) const
{
if (code_) {
return code_;
}
if (handle_ == nullptr) {
return std::nullopt;
}
const auto ms = timeout.count() < 0 ? 0 : static_cast<DWORD>(timeout.count());
if (WaitForSingleObject(static_cast<HANDLE>(handle_), ms) != WAIT_OBJECT_0) {
return std::nullopt;
}
DWORD code = 0;
if (GetExitCodeProcess(static_cast<HANDLE>(handle_), &code) == 0) {
return std::nullopt;
}
code_ = static_cast<int>(code);
return code_;
}
void CoreProcess::terminate()
{
if (handle_ != nullptr && running()) {
TerminateProcess(static_cast<HANDLE>(handle_), 1);
(void)wait_for(std::chrono::milliseconds(2000));
}
}
bool CoreProcess::reap_deadline_ms(int /*grace_ms*/) const
{
return !running();
}
#else // POSIX
std::uint32_t current_process_id()
{
return static_cast<std::uint32_t>(getpid());
}
bool is_process_alive(std::uint32_t pid)
{
if (pid == 0) {
return false;
}
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::error_code ec;
if (!std::filesystem::is_regular_file(exe, ec)) {
return std::nullopt;
}
posix_spawn_file_actions_t actions;
posix_spawn_file_actions_init(&actions);
if (!options.log_path.empty()) {
const int log = ::open(options.log_path.c_str(), O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0600);
if (log < 0) {
posix_spawn_file_actions_destroy(&actions);
return std::nullopt;
}
const std::string header = header_line();
(void)!::write(log, header.data(), header.size());
::close(log);
posix_spawn_file_actions_addopen(&actions, 1, options.log_path.c_str(), O_WRONLY | O_APPEND, 0600);
posix_spawn_file_actions_adddup2(&actions, 1, 2);
}
posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0);
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);
pid_t pid = 0;
const int result = posix_spawn(&pid, storage.front().c_str(), &actions, nullptr, argv.data(), environ);
posix_spawn_file_actions_destroy(&actions);
if (result != 0) {
return std::nullopt;
}
CoreProcess process;
process.pid_ = static_cast<std::uint32_t>(pid);
return process;
}
CoreProcess::~CoreProcess() = default; // pas de handle ; le processus n'est pas tué
bool CoreProcess::running() const
{
return pid_ != 0 && !wait_for(std::chrono::milliseconds(0));
}
std::optional<int> CoreProcess::wait_for(std::chrono::milliseconds timeout) const
{
if (code_ || pid_ == 0) {
return code_;
}
const auto deadline = std::chrono::steady_clock::now() + timeout;
for (;;) {
int status = 0;
const pid_t done = ::waitpid(static_cast<pid_t>(pid_), &status, WNOHANG);
if (done == static_cast<pid_t>(pid_)) {
code_ = WIFEXITED(status) ? WEXITSTATUS(status) : 128 + WTERMSIG(status);
return code_;
}
if (done < 0 && errno != EINTR) {
return std::nullopt;
}
if (std::chrono::steady_clock::now() >= deadline) {
return std::nullopt;
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
bool CoreProcess::reap_deadline_ms(int grace_ms) const
{
return wait_for(std::chrono::milliseconds(grace_ms)).has_value();
}
void CoreProcess::terminate()
{
if (pid_ == 0 || code_) {
return;
}
::kill(static_cast<pid_t>(pid_), SIGTERM);
if (!reap_deadline_ms(1000)) {
::kill(static_cast<pid_t>(pid_), SIGKILL);
(void)reap_deadline_ms(2000);
}
}
#endif
CoreProcess::CoreProcess(CoreProcess&& 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
{
if (this != &other) {
this->~CoreProcess();
pid_ = std::exchange(other.pid_, 0);
handle_ = std::exchange(other.handle_, nullptr);
code_ = std::exchange(other.code_, std::nullopt);
}
return *this;
}
std::uint32_t CoreProcess::pid() const noexcept
{
return pid_;
}
} // namespace sequa::studio::host
+99
View File
@@ -0,0 +1,99 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/studio/host/random.hpp"
#include <cstddef>
#include <optional>
#include <string>
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#include <bcrypt.h>
#else
#include <cerrno>
#include <cstdio>
#if defined(__linux__)
#include <sys/random.h>
#endif
#include <unistd.h>
#endif
namespace sequa::studio::host {
namespace {
#if !defined(_WIN32)
// Repli : /dev/urandom (macOS, ou Linux sans getrandom).
bool fill_from_urandom(unsigned char* out, std::size_t bytes, std::size_t done)
{
std::FILE* source = std::fopen("/dev/urandom", "rb");
if (source == nullptr) {
return false;
}
while (done < bytes) {
std::size_t const read = std::fread(out + done, 1u, bytes - done, source);
if (read == 0) {
std::fclose(source);
return false;
}
done += read;
}
std::fclose(source);
return true;
}
#endif
bool fill_random(unsigned char* out, std::size_t bytes)
{
if (bytes == 0) {
return true;
}
#if defined(_WIN32)
NTSTATUS const status = BCryptGenRandom(
nullptr, out, static_cast<ULONG>(bytes), BCRYPT_USE_SYSTEM_PREFERRED_RNG);
return BCRYPT_SUCCESS(status);
#elif defined(__linux__)
std::size_t done = 0;
while (done < bytes) {
long const n = static_cast<long>(
::getrandom(out + done, bytes - done, 0u));
if (n < 0) {
if (errno == EINTR) {
continue;
}
return fill_from_urandom(out, bytes, done);
}
if (n == 0) {
return false;
}
done += static_cast<std::size_t>(n);
}
return true;
#else
return fill_from_urandom(out, bytes, std::size_t{0});
#endif
}
} // namespace
std::optional<std::string> random_hex(std::size_t bytes)
{
if (bytes == 0) {
return std::string{};
}
std::string buffer(bytes, '\0');
if (!fill_random(reinterpret_cast<unsigned char*>(buffer.data()), bytes)) {
return std::nullopt;
}
static constexpr char digits[] = "0123456789abcdef";
std::string hex(2 * bytes, '0');
for (std::size_t i = 0; i < bytes; ++i) {
auto const byte = static_cast<unsigned char>(buffer[i]);
hex[2 * i] = digits[byte >> 4];
hex[2 * i + 1] = digits[byte & 0x0F];
}
return hex;
}
} // namespace sequa::studio::host
+161
View File
@@ -0,0 +1,161 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/studio/host/run_file.hpp"
#include "sequa/studio/host/random.hpp"
#include <nlohmann/json.hpp>
#include <fstream>
#include <iterator>
#include <limits>
#include <string>
#include <system_error>
#if !defined(_WIN32)
#include <fcntl.h>
#include <unistd.h>
#endif
namespace sequa::studio::host {
namespace {
using nlohmann::json;
// Chemins stockés en UTF-8 dans le JSON, quelle que soit la page de code.
std::string path_to_utf8(const std::filesystem::path& path)
{
const std::u8string text = path.u8string();
return std::string(text.begin(), text.end());
}
std::filesystem::path utf8_to_path(const std::string& text)
{
return std::filesystem::path(std::u8string(text.begin(), text.end()));
}
const json* string_field(const json& object, const char* name)
{
const auto it = object.find(name);
return it != object.end() && it->is_string() ? &*it : nullptr;
}
bool write_all(const std::filesystem::path& path, const std::string& content)
{
#if defined(_WIN32)
// Le dossier d'exécution est dans le profil de l'utilisateur (%LOCALAPPDATA%) :
// ses droits par défaut le réservent déjà à l'utilisateur.
std::ofstream file(path, std::ios::binary | std::ios::trunc);
if (!file) {
return false;
}
file.write(content.data(), static_cast<std::streamsize>(content.size()));
return static_cast<bool>(file.flush());
#else
// 0600 dès la création : le fichier contient le jeton.
const int fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
if (fd < 0) {
return false;
}
std::size_t done = 0;
while (done < content.size()) {
const ssize_t n = ::write(fd, content.data() + done, content.size() - done);
if (n <= 0) {
::close(fd);
return false;
}
done += static_cast<std::size_t>(n);
}
return ::fsync(fd) == 0 && ::close(fd) == 0;
#endif
}
} // namespace
std::optional<RunFile> read_run_file(const std::filesystem::path& path)
{
std::ifstream file(path, std::ios::binary);
if (!file) {
return std::nullopt;
}
const std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
const json data = json::parse(content, nullptr, false);
if (data.is_discarded() || !data.is_object()) {
return std::nullopt;
}
const auto pid = data.find("pid");
const auto port = data.find("port");
if (pid == data.end() || !pid->is_number_unsigned() || port == data.end() || !port->is_number_unsigned()) {
return std::nullopt;
}
const auto pid_value = pid->get<std::uint64_t>();
const auto port_value = port->get<std::uint64_t>();
if (pid_value == 0 || pid_value > std::numeric_limits<std::uint32_t>::max() || port_value == 0 ||
port_value > 65535) {
return std::nullopt;
}
const json* host = string_field(data, "host");
const json* token = string_field(data, "token");
const json* instance = string_field(data, "instanceId");
const json* exe = string_field(data, "exePath");
const json* source = string_field(data, "sourceExePath");
const json* started = string_field(data, "startedAt");
if (!host || !token || !instance || !exe || !source || !started || token->get_ref<const std::string&>().empty() ||
instance->get_ref<const std::string&>().empty()) {
return std::nullopt;
}
RunFile run;
run.pid = static_cast<std::uint32_t>(pid_value);
run.host = host->get<std::string>();
run.port = static_cast<std::uint16_t>(port_value);
run.token = token->get<std::string>();
run.instanceId = instance->get<std::string>();
run.exePath = utf8_to_path(exe->get<std::string>());
run.sourceExePath = utf8_to_path(source->get<std::string>());
run.startedAt = started->get<std::string>();
return run;
}
bool write_run_file(const std::filesystem::path& path, const RunFile& run)
{
const json data{{"pid", run.pid},
{"host", run.host},
{"port", run.port},
{"token", run.token},
{"instanceId", run.instanceId},
{"exePath", path_to_utf8(run.exePath)},
{"sourceExePath", path_to_utf8(run.sourceExePath)},
{"startedAt", run.startedAt}};
// Écriture atomique : fichier temporaire du même dossier, puis renommage.
const auto suffix = random_hex(4);
if (!suffix) {
return false;
}
std::filesystem::path temporary = path;
temporary += ".tmp-" + *suffix;
if (!write_all(temporary, data.dump(2) + "\n")) {
std::error_code ignored;
std::filesystem::remove(temporary, ignored);
return false;
}
std::error_code ec;
std::filesystem::rename(temporary, path, ec); // remplace l'existant
if (ec) {
std::filesystem::remove(temporary, ec);
return false;
}
return true;
}
bool remove_run_file(const std::filesystem::path& path)
{
std::error_code ec;
std::filesystem::remove(path, ec);
return !ec;
}
} // namespace sequa::studio::host
+115
View File
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/studio/host/shadow.hpp"
#include "sequa/studio/host/process.hpp"
#include "sequa/studio/host/time.hpp"
#include <cctype>
#include <string>
#include <string_view>
#include <system_error>
namespace sequa::studio::host {
namespace {
#if defined(_WIN32)
constexpr std::string_view executable_suffix = ".exe";
#else
constexpr std::string_view executable_suffix = "";
#endif
constexpr std::string_view prefix = "sequa-core-";
bool all_digits(std::string_view text)
{
if (text.empty()) {
return false;
}
for (const char c : text) {
if (std::isdigit(static_cast<unsigned char>(c)) == 0) {
return false;
}
}
return true;
}
} // namespace
bool is_shadow_copy_name(const std::string& filename)
{
std::string_view name = filename;
if (name.size() >= 4 && name.substr(name.size() - 4) == ".exe") {
name.remove_suffix(4);
}
if (name.substr(0, prefix.size()) != prefix) {
return false;
}
name.remove_prefix(prefix.size());
// Horodatage AAAAMMJJTHHMMSSZ (16 caractères).
if (name.size() < 18 || !all_digits(name.substr(0, 8)) || name[8] != 'T' || !all_digits(name.substr(9, 6)) ||
name[15] != 'Z' || name[16] != '-') {
return false;
}
name.remove_prefix(17);
// <pid>[-<n>]
const std::size_t dash = name.find('-');
if (dash == std::string_view::npos) {
return all_digits(name);
}
return all_digits(name.substr(0, dash)) && all_digits(name.substr(dash + 1));
}
std::optional<std::filesystem::path> make_shadow_copy(const std::filesystem::path& source,
const std::filesystem::path& bin_dir)
{
std::error_code ec;
if (!std::filesystem::is_regular_file(source, ec)) {
return std::nullopt;
}
std::filesystem::create_directories(bin_dir, ec);
if (ec) {
return std::nullopt;
}
const std::string base =
std::string(prefix) + utc_now_compact() + "-" + std::to_string(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);
const std::filesystem::path target = bin_dir / name;
if (std::filesystem::copy_file(source, target, std::filesystem::copy_options::none, ec)) {
return target;
}
if (ec != std::errc::file_exists) {
return std::nullopt; // autre erreur que « déjà présent »
}
}
return std::nullopt;
}
std::size_t cleanup_shadow_copies(const std::filesystem::path& bin_dir)
{
std::error_code ec;
std::size_t removed = 0;
std::filesystem::directory_iterator it(bin_dir, ec);
if (ec) {
return 0;
}
for (const auto& entry : it) {
const std::string name = entry.path().filename().string();
if (!is_shadow_copy_name(name) || !entry.is_regular_file(ec)) {
continue;
}
// Une copie en cours d'exécution est verrouillée sous Windows : échec ignoré.
std::error_code remove_error;
if (std::filesystem::remove(entry.path(), remove_error)) {
++removed;
}
}
return removed;
}
} // namespace sequa::studio::host
+70
View File
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/studio/host/time.hpp"
#include <ctime>
#include <string>
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#include <format>
#else
#include <ctime>
#endif
namespace sequa::studio::host {
namespace {
#if defined(_WIN32)
std::string format_system_time(bool compact)
{
SYSTEMTIME now{};
GetSystemTime(&now);
if (compact) {
return std::format("{:04d}{:02d}{:02d}T{:02d}{:02d}{:02d}Z",
now.wYear, now.wMonth, now.wDay, now.wHour, now.wMinute, now.wSecond);
}
return std::format("{:04d}-{:02d}-{:02d}T{:02d}:{:02d}:{:02d}Z",
now.wYear, now.wMonth, now.wDay, now.wHour, now.wMinute, now.wSecond);
}
#else
std::string format_gmtime(char const* pattern)
{
std::time_t const now = std::time(nullptr);
std::tm tm{};
if (gmtime_r(&now, &tm) == nullptr) {
return {};
}
char buffer[32];
std::size_t const written = std::strftime(buffer, sizeof(buffer), pattern, &tm);
return std::string(buffer, written);
}
#endif
} // namespace
std::string utc_now_iso8601()
{
#if defined(_WIN32)
return format_system_time(false);
#else
return format_gmtime("%Y-%m-%dT%H:%M:%SZ");
#endif
}
std::string utc_now_compact()
{
#if defined(_WIN32)
return format_system_time(true);
#else
return format_gmtime("%Y%m%dT%H%M%SZ");
#endif
}
} // namespace sequa::studio::host
+3
View File
@@ -0,0 +1,3 @@
// SPDX-License-Identifier: MPL-2.0
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
+33
View File
@@ -0,0 +1,33 @@
// 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/studio/host/random.hpp"
#include <filesystem>
#include <system_error>
namespace sequa::studio::host::testing {
class TempDir {
public:
TempDir() : path_(std::filesystem::temp_directory_path() / ("sequa-supervisor-" + random_hex(6).value()))
{
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:
std::filesystem::path path_;
};
} // namespace sequa::studio::host::testing
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: MPL-2.0
//
// Processus enfant des tests du superviseur. 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.
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <thread>
#include <vector>
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#include <shellapi.h>
#endif
namespace {
std::vector<std::string> arguments(int argc, char** argv)
{
std::vector<std::string> result;
#if defined(_WIN32)
(void)argc;
(void)argv;
int count = 0;
LPWSTR* wide = CommandLineToArgvW(GetCommandLineW(), &count);
for (int i = 1; i < count; ++i) {
const int size = WideCharToMultiByte(CP_UTF8, 0, wide[i], -1, nullptr, 0, nullptr, nullptr);
std::string text(static_cast<std::size_t>(size > 0 ? size - 1 : 0), '\0');
WideCharToMultiByte(CP_UTF8, 0, wide[i], -1, text.data(), size, nullptr, nullptr);
result.push_back(std::move(text));
}
LocalFree(wide);
#else
for (int i = 1; i < argc; ++i) {
result.emplace_back(argv[i]);
}
#endif
return result;
}
} // namespace
int main(int argc, char** argv)
{
const std::vector<std::string> args = arguments(argc, argv);
for (std::size_t i = 0; i < args.size(); ++i) {
const std::string& arg = args[i];
const bool has_value = i + 1 < args.size();
if (arg == "--out" && has_value) {
std::printf("%s\n", args[++i].c_str());
} else if (arg == "--err" && has_value) {
std::fprintf(stderr, "%s\n", args[++i].c_str());
} else if (arg == "--echo-rest") {
for (++i; i < args.size(); ++i) {
std::printf("[%s]\n", args[i].c_str());
}
} else if (arg == "--sleep" && has_value) {
std::fflush(stdout);
std::this_thread::sleep_for(std::chrono::milliseconds(std::atoi(args[++i].c_str())));
} else if (arg == "--exit" && has_value) {
std::fflush(stdout);
return std::atoi(args[++i].c_str());
}
}
return 0;
}
+102
View File
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/studio/host/files.hpp"
#include "sequa/studio/host/random.hpp"
#include "sequa/studio/host/time.hpp"
#include "temp_dir.hpp"
#include <doctest/doctest.h>
#include <chrono>
#include <fstream>
#include <string>
#include <thread>
using namespace sequa::studio::host;
using namespace std::chrono_literals;
namespace {
void write(const std::filesystem::path& path, const std::string& content)
{
std::ofstream file(path, std::ios::binary | std::ios::trunc);
file << content;
}
} // namespace
TEST_CASE("random_hex : longueur, alphabet, unicite")
{
const auto a = random_hex(16);
const auto b = random_hex(16);
REQUIRE(a.has_value());
REQUIRE(b.has_value());
CHECK(a->size() == 32);
CHECK(a->find_first_not_of("0123456789abcdef") == std::string::npos);
CHECK(*a != *b);
CHECK(random_hex(0) == std::string());
}
TEST_CASE("Horodatages UTC")
{
const std::string iso = utc_now_iso8601();
CHECK(iso.size() == 20);
CHECK(iso[4] == '-');
CHECK(iso[10] == 'T');
CHECK(iso.back() == 'Z');
const std::string compact = utc_now_compact();
CHECK(compact.size() == 16);
CHECK(compact[8] == 'T');
CHECK(compact.back() == 'Z');
}
TEST_CASE("read_last_lines : fin de fichier, CRLF, fichier absent")
{
testing::TempDir dir;
const auto path = dir.path() / "journal.log";
write(path, "un\r\ndeux\ntrois\r\nquatre\n");
const auto last = read_last_lines(path, 2);
REQUIRE(last.size() == 2);
CHECK(last[0] == "trois");
CHECK(last[1] == "quatre");
CHECK(read_last_lines(path, 10).size() == 4);
CHECK(read_last_lines(dir.path() / "absent.log", 5).empty());
}
TEST_CASE("read_last_lines : gros fichier lu seulement par la fin")
{
testing::TempDir dir;
const auto path = dir.path() / "gros.log";
{
std::ofstream file(path, std::ios::binary);
for (int i = 0; i < 200000; ++i) {
file << "ligne numero " << i << "\n";
}
}
const auto last = read_last_lines(path, 3);
REQUIRE(last.size() == 3);
CHECK(last[2] == "ligne numero 199999");
}
TEST_CASE("FileWatcher : changement signale seulement apres stabilite, une fois")
{
testing::TempDir dir;
const auto path = dir.path() / "sequa-core.exe";
write(path, "v1");
FileWatcher watcher(path, 150ms);
CHECK_FALSE(watcher.poll()); // état initial : rien de nouveau
write(path, "version 2 plus longue");
CHECK_FALSE(watcher.poll()); // changé mais pas encore stable
std::this_thread::sleep_for(60ms);
write(path, "version 3, encore en cours d'ecriture");
CHECK_FALSE(watcher.poll()); // nouvelle modification : le compteur repart
std::this_thread::sleep_for(200ms);
CHECK(watcher.poll());
CHECK_FALSE(watcher.poll()); // une seule fois par changement
std::filesystem::remove(path);
CHECK_FALSE(watcher.poll());
std::this_thread::sleep_for(200ms);
CHECK_FALSE(watcher.poll()); // une suppression n'est pas un nouvel exécutable
}
@@ -0,0 +1,151 @@
// 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 "temp_dir.hpp"
#include <doctest/doctest.h>
#include <algorithm>
#include <chrono>
#include <string>
#include <vector>
using namespace sequa::studio::host;
using namespace std::chrono_literals;
namespace {
const std::filesystem::path child = SEQUA_TEST_CHILD;
bool contains(const std::vector<std::string>& lines, const std::string& expected)
{
return std::find(lines.begin(), lines.end(), expected) != lines.end();
}
} // namespace
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);
LaunchOptions options;
options.log_path = log;
auto first = CoreProcess::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);
REQUIRE(second.has_value());
CHECK(second->wait_for(5s) == 0);
const auto lines = read_last_lines(log, 100);
CHECK(contains(lines, "premier"));
CHECK(contains(lines, "erreur"));
CHECK(contains(lines, "second")); // le premier lancement n'a pas été écrasé
const auto headers = std::count_if(lines.begin(), lines.end(),
[](const std::string& line) { return line.starts_with("===== "); });
CHECK(headers == 2);
}
TEST_CASE("Processus : arguments avec espaces, guillemets et antislashs")
{
testing::TempDir dir;
LaunchOptions options;
options.log_path = dir.path() / "echo.log";
const std::vector<std::string> tricky = {"simple", "avec espace", "guillemet \" milieu",
"fin antislash\\", "C:\\Program Files\\", "\\\\serveur\\\"partage",
"", "accentué é"};
std::vector<std::string> args = {"--echo-rest"};
args.insert(args.end(), tricky.begin(), tricky.end());
auto process = CoreProcess::launch(child, args, options);
REQUIRE(process.has_value());
REQUIRE(process->wait_for(5s) == 0);
const auto lines = read_last_lines(options.log_path, 100);
for (const std::string& argument : tricky) {
CAPTURE(argument);
CHECK(contains(lines, "[" + argument + "]"));
}
}
TEST_CASE("Processus : wait_for avec delai, terminate, is_process_alive")
{
auto process = CoreProcess::launch(child, {"--sleep", "10000"});
REQUIRE(process.has_value());
CHECK(process->running());
CHECK(is_process_alive(process->pid()));
CHECK_FALSE(process->wait_for(100ms).has_value());
process->terminate();
CHECK_FALSE(process->running());
CHECK(process->wait_for(0ms).has_value());
CHECK_FALSE(is_process_alive(process->pid()));
CHECK_FALSE(is_process_alive(0));
}
TEST_CASE("Processus : executable absent -> nullopt ; deplacement")
{
CHECK_FALSE(CoreProcess::launch("introuvable-sequa.exe", {}).has_value());
auto process = CoreProcess::launch(child, {"--exit", "3"});
REQUIRE(process.has_value());
CoreProcess moved = std::move(*process);
CHECK(moved.wait_for(5s) == 3);
}
TEST_CASE("Processus : le destructeur ne tue pas le processus")
{
std::uint32_t pid = 0;
{
auto process = CoreProcess::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")
{
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"));
}
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/studio/host/paths.hpp"
#include "sequa/studio/host/run_file.hpp"
#include "temp_dir.hpp"
#include <doctest/doctest.h>
#include <fstream>
using namespace sequa::studio::host;
namespace {
RunFile sample()
{
RunFile run;
run.pid = 4321;
run.port = 9100;
run.token = "0123456789abcdef0123456789abcdef";
run.instanceId = "fedcba9876543210fedcba9876543210";
run.exePath = std::filesystem::path(u8"C:/SEQUA/run/bin/sequa-core-20261106T210000Z-1.exe");
run.sourceExePath = std::filesystem::path(u8"E:/projets/déjà vu/build/sequa-core.exe");
run.startedAt = "2026-11-06T21:00:00Z";
return run;
}
void write_raw(const std::filesystem::path& path, const std::string& content)
{
std::ofstream file(path, std::ios::binary | std::ios::trunc);
file << content;
}
} // namespace
TEST_CASE("Fichier d'execution : chemins par port")
{
const std::filesystem::path root = "racine";
CHECK(run_file_path(root, 9100) == root / "core-9100.json");
CHECK(log_file_path(root, 9101) == root / "core-9101.log");
CHECK(bin_dir(root) == root / "bin");
}
TEST_CASE("Fichier d'execution : aller-retour, chemin non ASCII, remplacement atomique")
{
testing::TempDir dir;
REQUIRE(ensure_run_dirs(dir.path()));
CHECK(std::filesystem::is_directory(bin_dir(dir.path())));
const auto path = run_file_path(dir.path(), 9100);
RunFile run = sample();
REQUIRE(write_run_file(path, run));
CHECK(read_run_file(path) == run);
run.pid = 99;
run.instanceId = "00000000000000000000000000000000";
REQUIRE(write_run_file(path, run)); // remplace l'existant
CHECK(read_run_file(path) == run);
// Aucun fichier temporaire ne traîne.
std::size_t files = 0;
for (const auto& entry : std::filesystem::directory_iterator(dir.path())) {
files += entry.is_regular_file() ? 1 : 0;
}
CHECK(files == 1);
CHECK(remove_run_file(path));
CHECK(remove_run_file(path)); // déjà absent : succès
CHECK_FALSE(read_run_file(path).has_value());
}
TEST_CASE("Fichier d'execution : contenu corrompu ou incomplet -> nullopt")
{
testing::TempDir dir;
const auto path = dir.path() / "core-9100.json";
for (const char* content : {"", "pas du json", "[1,2]", R"({"pid":1})",
R"({"pid":0,"host":"h","port":9100,"token":"t","instanceId":"i","exePath":"e","sourceExePath":"s","startedAt":"d"})",
R"({"pid":1,"host":"h","port":70000,"token":"t","instanceId":"i","exePath":"e","sourceExePath":"s","startedAt":"d"})",
R"({"pid":1,"host":"h","port":9100,"token":"","instanceId":"i","exePath":"e","sourceExePath":"s","startedAt":"d"})",
R"({"pid":-5,"host":"h","port":9100,"token":"t","instanceId":"i","exePath":"e","sourceExePath":"s","startedAt":"d"})"}) {
CAPTURE(content);
write_raw(path, content);
CHECK_FALSE(read_run_file(path).has_value());
}
write_raw(path, R"({"pid":1,"host":"h","port":9100,"token":"t","instanceId":"i","exePath":"e","sourceExePath":"s","startedAt":"d","inconnu":true})");
CHECK(read_run_file(path).has_value()); // champs inconnus ignorés
}