From 832bd52fc9bf2f2fdf80c439cbad43e871a2952b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ange-Marie=20MAURIN=20-=20A2M=20Syst=C3=A8mes?= Date: Fri, 6 Nov 2026 23:06:34 +0100 Subject: [PATCH] feat(studio/host): brique supervisor, cycle de vie du Core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- studio/host/supervisor/CMakeLists.txt | 43 +++ studio/host/supervisor/README.md | 22 ++ .../include/sequa/studio/host/files.hpp | 56 +++ .../include/sequa/studio/host/paths.hpp | 32 ++ .../include/sequa/studio/host/process.hpp | 88 +++++ .../include/sequa/studio/host/random.hpp | 16 + .../include/sequa/studio/host/run_file.hpp | 40 ++ .../include/sequa/studio/host/shadow.hpp | 31 ++ .../include/sequa/studio/host/time.hpp | 18 + studio/host/supervisor/src/files.cpp | 96 +++++ studio/host/supervisor/src/paths.cpp | 87 +++++ studio/host/supervisor/src/process.cpp | 357 ++++++++++++++++++ studio/host/supervisor/src/random.cpp | 99 +++++ studio/host/supervisor/src/run_file.cpp | 161 ++++++++ studio/host/supervisor/src/shadow.cpp | 115 ++++++ studio/host/supervisor/src/time.cpp | 70 ++++ studio/host/supervisor/tests/main.cpp | 3 + studio/host/supervisor/tests/temp_dir.hpp | 33 ++ studio/host/supervisor/tests/test_child.cpp | 75 ++++ studio/host/supervisor/tests/test_files.cpp | 102 +++++ studio/host/supervisor/tests/test_process.cpp | 151 ++++++++ .../host/supervisor/tests/test_run_file.cpp | 87 +++++ 22 files changed, 1782 insertions(+) create mode 100644 studio/host/supervisor/CMakeLists.txt create mode 100644 studio/host/supervisor/README.md create mode 100644 studio/host/supervisor/include/sequa/studio/host/files.hpp create mode 100644 studio/host/supervisor/include/sequa/studio/host/paths.hpp create mode 100644 studio/host/supervisor/include/sequa/studio/host/process.hpp create mode 100644 studio/host/supervisor/include/sequa/studio/host/random.hpp create mode 100644 studio/host/supervisor/include/sequa/studio/host/run_file.hpp create mode 100644 studio/host/supervisor/include/sequa/studio/host/shadow.hpp create mode 100644 studio/host/supervisor/include/sequa/studio/host/time.hpp create mode 100644 studio/host/supervisor/src/files.cpp create mode 100644 studio/host/supervisor/src/paths.cpp create mode 100644 studio/host/supervisor/src/process.cpp create mode 100644 studio/host/supervisor/src/random.cpp create mode 100644 studio/host/supervisor/src/run_file.cpp create mode 100644 studio/host/supervisor/src/shadow.cpp create mode 100644 studio/host/supervisor/src/time.cpp create mode 100644 studio/host/supervisor/tests/main.cpp create mode 100644 studio/host/supervisor/tests/temp_dir.hpp create mode 100644 studio/host/supervisor/tests/test_child.cpp create mode 100644 studio/host/supervisor/tests/test_files.cpp create mode 100644 studio/host/supervisor/tests/test_process.cpp create mode 100644 studio/host/supervisor/tests/test_run_file.cpp diff --git a/studio/host/supervisor/CMakeLists.txt b/studio/host/supervisor/CMakeLists.txt new file mode 100644 index 0000000..a99b665 --- /dev/null +++ b/studio/host/supervisor/CMakeLists.txt @@ -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="$") + 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() diff --git a/studio/host/supervisor/README.md b/studio/host/supervisor/README.md new file mode 100644 index 0000000..b6102fe --- /dev/null +++ b/studio/host/supervisor/README.md @@ -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-.json`, `core-.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. diff --git a/studio/host/supervisor/include/sequa/studio/host/files.hpp b/studio/host/supervisor/include/sequa/studio/host/files.hpp new file mode 100644 index 0000000..5173e99 --- /dev/null +++ b/studio/host/supervisor/include/sequa/studio/host/files.hpp @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include + +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 +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 last_change_; +}; + +} // namespace sequa::studio::host \ No newline at end of file diff --git a/studio/host/supervisor/include/sequa/studio/host/paths.hpp b/studio/host/supervisor/include/sequa/studio/host/paths.hpp new file mode 100644 index 0000000..ef98122 --- /dev/null +++ b/studio/host/supervisor/include/sequa/studio/host/paths.hpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include +#include +#include + +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 default_run_dir(); + +// Sous-dossier des copies fantômes : /bin (§7.5). +[[nodiscard]] std::filesystem::path bin_dir(std::filesystem::path const& run_dir); + +// Fichier d'exécution du Core d'un port : /core-.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 : /core-.log (§7.5). +[[nodiscard]] std::filesystem::path log_file_path(std::filesystem::path const& run_dir, std::uint16_t port); + +// Crée et /bin (parents compris). Échec -> false, +// jamais d'exception. +[[nodiscard]] bool ensure_run_dirs(std::filesystem::path const& run_dir); + +} // namespace sequa::studio::host \ No newline at end of file diff --git a/studio/host/supervisor/include/sequa/studio/host/process.hpp b/studio/host/supervisor/include/sequa/studio/host/process.hpp new file mode 100644 index 0000000..b01b2b7 --- /dev/null +++ b/studio/host/supervisor/include/sequa/studio/host/process.hpp @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include + +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 launch( + std::filesystem::path const& exe, + std::vector 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 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 code_; // code de sortie connu (POSIX : waitpid) +}; + +} // namespace sequa::studio::host \ No newline at end of file diff --git a/studio/host/supervisor/include/sequa/studio/host/random.hpp b/studio/host/supervisor/include/sequa/studio/host/random.hpp new file mode 100644 index 0000000..988abdc --- /dev/null +++ b/studio/host/supervisor/include/sequa/studio/host/random.hpp @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include +#include +#include + +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 random_hex(std::size_t bytes); + +} // namespace sequa::studio::host \ No newline at end of file diff --git a/studio/host/supervisor/include/sequa/studio/host/run_file.hpp b/studio/host/supervisor/include/sequa/studio/host/run_file.hpp new file mode 100644 index 0000000..4a09847 --- /dev/null +++ b/studio/host/supervisor/include/sequa/studio/host/run_file.hpp @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include +#include +#include +#include + +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 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 \ No newline at end of file diff --git a/studio/host/supervisor/include/sequa/studio/host/shadow.hpp b/studio/host/supervisor/include/sequa/studio/host/shadow.hpp new file mode 100644 index 0000000..e4fcfb7 --- /dev/null +++ b/studio/host/supervisor/include/sequa/studio/host/shadow.hpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include +#include +#include +#include + +namespace sequa::studio::host { + +// Copie fantôme de l'exécutable source dans bin_dir (§7.5) : +// /sequa-core--[.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 - est +// ajouté si le nom existe déjà (deux copies dans la même seconde). Échec -> +// nullopt, jamais d'exception. +[[nodiscard]] std::optional +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--[-][.exe] ? +[[nodiscard]] bool is_shadow_copy_name(std::string const& filename); + +} // namespace sequa::studio::host \ No newline at end of file diff --git a/studio/host/supervisor/include/sequa/studio/host/time.hpp b/studio/host/supervisor/include/sequa/studio/host/time.hpp new file mode 100644 index 0000000..2300ad3 --- /dev/null +++ b/studio/host/supervisor/include/sequa/studio/host/time.hpp @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include + +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 \ No newline at end of file diff --git a/studio/host/supervisor/src/files.cpp b/studio/host/supervisor/src/files.cpp new file mode 100644 index 0000000..5463dd5 --- /dev/null +++ b/studio/host/supervisor/src/files.cpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/studio/host/files.hpp" + +#include +#include +#include +#include + +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(modified.time_since_epoch()).count(); + return result; +} + +std::vector read_last_lines(const std::filesystem::path& path, std::size_t max_lines) +{ + constexpr std::streamoff window = 1024 * 1024; + std::vector 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(size - start), '\0'); + file.read(content.data(), static_cast(content.size())); + content.resize(static_cast(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(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 diff --git a/studio/host/supervisor/src/paths.cpp b/studio/host/supervisor/src/paths.cpp new file mode 100644 index 0000000..b8ff72f --- /dev/null +++ b/studio/host/supervisor/src/paths.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/studio/host/paths.hpp" + +#include +#include +#include +#include +#include + +namespace sequa::studio::host { +namespace { + +#if defined(_WIN32) +// %LOCALAPPDATA% peut contenir des caractères non ANSI : lecture en grand. +std::optional 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 result; + if (*value != L'\0') { + result = std::filesystem::path(value); + } + std::free(value); + return result; +} +#else +std::optional 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 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 \ No newline at end of file diff --git a/studio/host/supervisor/src/process.cpp b/studio/host/supervisor/src/process.cpp new file mode 100644 index 0000000..26d92d4 --- /dev/null +++ b/studio/host/supervisor/src/process.cpp @@ -0,0 +1,357 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/studio/host/process.hpp" + +#include "sequa/studio/host/time.hpp" + +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#include +#include +#include +#include +#include +#include +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(text.size()), nullptr, 0); + std::wstring wide(static_cast(size), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(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(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::launch(const std::filesystem::path& exe, const std::vector& 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(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 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(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(info.dwProcessId); + process.handle_ = info.hProcess; + return process; +} + +CoreProcess::~CoreProcess() +{ + if (handle_ != nullptr) { + CloseHandle(static_cast(handle_)); + } +} + +bool CoreProcess::running() const +{ + return handle_ != nullptr && WaitForSingleObject(static_cast(handle_), 0) == WAIT_TIMEOUT; +} + +std::optional 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(timeout.count()); + if (WaitForSingleObject(static_cast(handle_), ms) != WAIT_OBJECT_0) { + return std::nullopt; + } + DWORD code = 0; + if (GetExitCodeProcess(static_cast(handle_), &code) == 0) { + return std::nullopt; + } + code_ = static_cast(code); + return code_; +} + +void CoreProcess::terminate() +{ + if (handle_ != nullptr && running()) { + TerminateProcess(static_cast(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(getpid()); +} + +bool is_process_alive(std::uint32_t pid) +{ + if (pid == 0) { + return false; + } + return ::kill(static_cast(pid), 0) == 0 || errno == EPERM; +} + +std::optional CoreProcess::launch(const std::filesystem::path& exe, const std::vector& 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 storage; + storage.reserve(args.size() + 1); + storage.push_back(exe.string()); + storage.insert(storage.end(), args.begin(), args.end()); + std::vector 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(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 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_), &status, WNOHANG); + if (done == static_cast(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_), SIGTERM); + if (!reap_deadline_ms(1000)) { + ::kill(static_cast(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 diff --git a/studio/host/supervisor/src/random.cpp b/studio/host/supervisor/src/random.cpp new file mode 100644 index 0000000..63b66f7 --- /dev/null +++ b/studio/host/supervisor/src/random.cpp @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/studio/host/random.hpp" + +#include +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#else +#include +#include +#if defined(__linux__) +#include +#endif +#include +#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(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( + ::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(n); + } + return true; +#else + return fill_from_urandom(out, bytes, std::size_t{0}); +#endif +} + +} // namespace + +std::optional random_hex(std::size_t bytes) +{ + if (bytes == 0) { + return std::string{}; + } + std::string buffer(bytes, '\0'); + if (!fill_random(reinterpret_cast(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(buffer[i]); + hex[2 * i] = digits[byte >> 4]; + hex[2 * i + 1] = digits[byte & 0x0F]; + } + return hex; +} + +} // namespace sequa::studio::host \ No newline at end of file diff --git a/studio/host/supervisor/src/run_file.cpp b/studio/host/supervisor/src/run_file.cpp new file mode 100644 index 0000000..aab2045 --- /dev/null +++ b/studio/host/supervisor/src/run_file.cpp @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/studio/host/run_file.hpp" + +#include "sequa/studio/host/random.hpp" + +#include + +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#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(content.size())); + return static_cast(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(n); + } + return ::fsync(fd) == 0 && ::close(fd) == 0; +#endif +} + +} // namespace + +std::optional 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(file)), std::istreambuf_iterator()); + 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(); + const auto port_value = port->get(); + if (pid_value == 0 || pid_value > std::numeric_limits::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().empty() || + instance->get_ref().empty()) { + return std::nullopt; + } + + RunFile run; + run.pid = static_cast(pid_value); + run.host = host->get(); + run.port = static_cast(port_value); + run.token = token->get(); + run.instanceId = instance->get(); + run.exePath = utf8_to_path(exe->get()); + run.sourceExePath = utf8_to_path(source->get()); + run.startedAt = started->get(); + 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 diff --git a/studio/host/supervisor/src/shadow.cpp b/studio/host/supervisor/src/shadow.cpp new file mode 100644 index 0000000..6be1af8 --- /dev/null +++ b/studio/host/supervisor/src/shadow.cpp @@ -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 +#include +#include +#include + +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(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); + + // [-] + 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 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 diff --git a/studio/host/supervisor/src/time.cpp b/studio/host/supervisor/src/time.cpp new file mode 100644 index 0000000..71435ec --- /dev/null +++ b/studio/host/supervisor/src/time.cpp @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/studio/host/time.hpp" + +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#else +#include +#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 \ No newline at end of file diff --git a/studio/host/supervisor/tests/main.cpp b/studio/host/supervisor/tests/main.cpp new file mode 100644 index 0000000..ecb715c --- /dev/null +++ b/studio/host/supervisor/tests/main.cpp @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: MPL-2.0 +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include diff --git a/studio/host/supervisor/tests/temp_dir.hpp b/studio/host/supervisor/tests/temp_dir.hpp new file mode 100644 index 0000000..f73b15a --- /dev/null +++ b/studio/host/supervisor/tests/temp_dir.hpp @@ -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 +#include + +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 diff --git a/studio/host/supervisor/tests/test_child.cpp b/studio/host/supervisor/tests/test_child.cpp new file mode 100644 index 0000000..3288e6f --- /dev/null +++ b/studio/host/supervisor/tests/test_child.cpp @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// Processus enfant des tests du superviseur. Arguments traités dans l'ordre : +// --out ligne sur stdout --err ligne sur stderr +// --echo-rest recopie chaque argument suivant entre crochets, un par ligne +// --sleep attend --exit 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 +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#endif + +namespace { + +std::vector arguments(int argc, char** argv) +{ + std::vector 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(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 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; +} diff --git a/studio/host/supervisor/tests/test_files.cpp b/studio/host/supervisor/tests/test_files.cpp new file mode 100644 index 0000000..1a9ea30 --- /dev/null +++ b/studio/host/supervisor/tests/test_files.cpp @@ -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 + +#include +#include +#include +#include + +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 +} diff --git a/studio/host/supervisor/tests/test_process.cpp b/studio/host/supervisor/tests/test_process.cpp new file mode 100644 index 0000000..30a4343 --- /dev/null +++ b/studio/host/supervisor/tests/test_process.cpp @@ -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 + +#include +#include +#include +#include + +using namespace sequa::studio::host; +using namespace std::chrono_literals; + +namespace { + +const std::filesystem::path child = SEQUA_TEST_CHILD; + +bool contains(const std::vector& 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 tricky = {"simple", "avec espace", "guillemet \" milieu", + "fin antislash\\", "C:\\Program Files\\", "\\\\serveur\\\"partage", + "", "accentué é"}; + std::vector 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")); +} diff --git a/studio/host/supervisor/tests/test_run_file.cpp b/studio/host/supervisor/tests/test_run_file.cpp new file mode 100644 index 0000000..99c37f3 --- /dev/null +++ b/studio/host/supervisor/tests/test_run_file.cpp @@ -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 + +#include + +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 +}