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:
1 parent
a185b3414a
commit
832bd52fc9
22 files changed
+1782
No files matched your search
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
|
||||
#include <doctest/doctest.h>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user