feat(studio/host): sequa-studio, démarrage/rattachement du Core et rechargement par rejeu

StudioHost : rattachement via le fichier d'exécution validé par instanceId, sinon lancement depuis une copie fantôme (jeton aléatoire, origines autorisées, --linked) ; reload = export (enregistrement + empreintes), arrêt, nouvel exécutable, import par rejeu, comparaison tick par tick ; restart ; detach/attach réversibles ; arrêt propre. sequa-studio : options §7.1, interface servie ou --dev, navigateur, commandes console, --watch. Tests de bout en bout contre le vrai Core (rechargement identique, rattachement d'un Core détaché, pas d'orphelin). Interface passée au protocole 0.2, jeton masqué dans le libellé. 111 tests verts en Debug et ASan.
This commit is contained in:
Ange-Marie MAURIN - A2M Systèmes committed 2026-11-06 23:15:23 +01:00
1 parent 832bd52fc9
commit d9f1b0c24e
16 files changed
+1261 -10

No files matched your search

+2 -1
View File
@@ -36,7 +36,8 @@ Il n'y a pas d'intégration continue sur serveur : les vérifications se lancent
| Windows | `.\scripts\build.ps1 [-Preset <nom>] [-Clean] [-NoTests]` | Visual Studio 2022 avec outils C++ (MSVC, CMake et Ninja fournis) |
| Windows, avant commit | `.\scripts\check.ps1` | idem + Node.js pour Studio |
| Linux / macOS | `./scripts/build.sh [<preset>]` | CMake ≥ 3.25, Ninja, GCC ou Clang |
| Lancer Studio | `.\scripts\studio.ps1 [-Core ws://hôte:port]` ou `./scripts/studio.sh [ws://hôte:port]` | Node.js 24 |
| Lancer SEQUA Studio (Core + interface) | `.\build\windows-msvc-debug\studio\host\app\sequa-studio.exe [--dev] [--watch]` (voir [studio/host](studio/host/README.md)) | build + `npm run build` dans `studio/ui` |
| Interface seule (développement) | `.\scripts\studio.ps1 [-Core ws://hôte:port] [-MockCore]` ou `./scripts/studio.sh` | Node.js 24 |
| Windows, cuDNN pour l'inférence GPU | `.\scripts\install-cudnn.ps1 [-Version <x.y.z>] [-CudaMajor 12\|13] [-DryRun]` | CUDA Toolkit installé + GPU NVIDIA ; télécharge cuDNN (~1,3 Go), demande d'accepter la licence NVIDIA et définit `CUDNN_PATH` (utilisateur, sans droits admin) |
Chaque script PowerShell affiche son aide avec `-Help`.
+2 -2
View File
@@ -17,7 +17,7 @@ Ports par défaut : Core **9100**, interface servie par le host **9180**, faux C
## 2. Enveloppe et versions
- Enveloppe : `{ "v": 1, "type": "<type>", "id": "<chaîne>"?, "payload": { ... } }`. `v` reste 1.
- `id` (facultatif) : identifiant de requête choisi par le client. Toute réponse à une requête qui portait un `id` recopie cet `id`. Une requête sans `id` reçoit quand même sa réponse, sans `id`.
- `id` (facultatif) : identifiant de requête choisi par le client. Toute réponse à une requête qui portait un `id` recopie cet `id`. Une requête sans `id` reçoit quand même sa réponse, sans `id`. `hello` est envoyé sans `id` (le host et l'UI le font ainsi) ; `welcome` et les erreurs de poignée de main n'en portent pas.
- Version du protocole : **`0.2.0`**. Compatibilité inchangée : même majeure et, en 0.x, même mineure. 0.1 et 0.2 sont donc incompatibles.
- `payload` est toujours un objet (éventuellement vide `{}`).
- Les entiers 64 bits (empreintes) sont transmis en **chaîne hexadécimale de 16 caractères minuscules**, jamais en nombre JSON (perte de précision en JavaScript au-delà de 2^53).
@@ -128,7 +128,7 @@ Le Core est lancé depuis `<run>/bin/sequa-core-<horodatage UTC compact>-<pid du
2. `core/shutdown` ; attendre la fin du processus (3 s), sinon terminaison forcée.
3. Nouvelle copie fantôme du **nouvel** exécutable source, lancement (mêmes port et jeton), `hello`, `core/attach`.
4. `session/import { recording, resume: <running>, includeHashes: true }`.
5. Comparer les empreintes tick par tick : afficher « identique » ou « divergence au tick N », ainsi que `replayMs`.
5. Comparer les empreintes tick par tick : afficher « identique » ou « divergence au tick N », ainsi que `replayMs`. Si les deux tableaux n'ont pas la même longueur, la divergence est au premier tick absent de l'un des deux.
6. Mettre à jour le fichier d'exécution (nouveau `pid`, `instanceId`, `exePath`).
`restart` fait la même chose avec le même exécutable, sans comparaison.
+29
View File
@@ -0,0 +1,29 @@
# Studio host (`sequa-studio`)
Face C++ de SEQUA Studio ([README de Studio](../README.md)) : l'exécutable que l'auteur lance. Il démarre ou rejoint le Core, le supervise, le recharge par rejeu de la session après recompilation, et sert l'interface ([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)).
| Brique | Rôle |
|---|---|
| [supervisor/](supervisor) | Copie fantôme, lancement et suivi du processus Core, fichier d'exécution, journaux, surveillance de l'exécutable |
| [client/](client) | Client C++ du protocole 0.2 (requêtes corrélées, délais, empreintes) |
| [web/](web) | Service HTTP de `studio/ui/dist`, ouverture du navigateur |
| [app/](app) | `StudioHost` (orchestration, rechargement par rejeu) et l'exécutable `sequa-studio` |
## Utilisation
```powershell
.\scripts\build.ps1 # construit Core, host et tests
cd studio\ui; npm run build; cd ..\.. # construit l'interface servie
.\build\windows-msvc-debug\studio\host\app\sequa-studio.exe # Core + interface + navigateur
.\build\windows-msvc-debug\studio\host\app\sequa-studio.exe --dev # avec Vite (scripts\studio.ps1 dans un autre terminal)
.\build\windows-msvc-debug\studio\host\app\sequa-studio.exe --watch # recharge le Core à chaque recompilation
```
Commandes dans la console : `status`, `reload` (nouvel exécutable du Core, session rejouée et comparée tick par tick), `restart`, `detach` / `attach` (le Core survit ou non à Studio, réversible), `log`, `quit`.
Par défaut le Core est **lié** à Studio : si Studio s'arrête ou plante, le Core s'arrête après un délai de grâce de 3 s. Aucun Core orphelin ne garde la mémoire ni, plus tard, la VRAM.
## Licences
- MPL 2.0.
- Dépendances : IXWebSocket 12.1.0 (BSD-3-Clause), nlohmann/json 3.12.0 (MIT).
+28
View File
@@ -0,0 +1,28 @@
# SPDX-License-Identifier: MPL-2.0
#
# Exécutable sequa-studio : assemble supervisor, client et web (spec 0.2 §7, ADR 0006).
add_library(sequa_host_app STATIC
src/options.cpp
src/studio_host.cpp)
target_include_directories(sequa_host_app PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src")
target_link_libraries(sequa_host_app PUBLIC sequa::host_supervisor sequa::host_client sequa::host_web)
sequa_target_defaults(sequa_host_app)
add_executable(sequa_studio_app src/main.cpp)
set_target_properties(sequa_studio_app PROPERTIES OUTPUT_NAME sequa-studio)
target_link_libraries(sequa_studio_app PRIVATE sequa_host_app)
sequa_target_defaults(sequa_studio_app)
if(SEQUA_BUILD_TESTS)
add_executable(sequa_host_app_tests
tests/main.cpp
tests/test_options.cpp
tests/test_studio_host.cpp)
target_link_libraries(sequa_host_app_tests PRIVATE sequa_host_app doctest::doctest)
# Test de bout en bout contre le vrai Core.
target_compile_definitions(sequa_host_app_tests PRIVATE SEQUA_TEST_CORE_EXE="$<TARGET_FILE:sequa_core_app>")
add_dependencies(sequa_host_app_tests sequa_core_app)
sequa_target_defaults(sequa_host_app_tests)
doctest_discover_tests(sequa_host_app_tests)
endif()
+282
View File
@@ -0,0 +1,282 @@
// SPDX-License-Identifier: MPL-2.0
//
// sequa-studio : démarre ou rejoint le Core, sert l'interface, ouvre le navigateur, puis
// lit des commandes sur la console (spec 0.2 §7, ADR 0006).
#include "options.hpp"
#include "studio_host.hpp"
#include "sequa/studio/host/browser.hpp"
#include "sequa/studio/host/files.hpp"
#include "sequa/studio/host/paths.hpp"
#include "sequa/studio/host/ui_server.hpp"
#include "sequa/studio/host/urls.hpp"
#include <atomic>
#include <chrono>
#include <csignal>
#include <cstdio>
#include <deque>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
namespace {
using namespace sequa::studio::host;
using namespace std::chrono_literals;
volatile std::sig_atomic_t interrupted = 0;
extern "C" void on_signal(int /*signal*/)
{
interrupted = 1;
}
std::mutex output_mutex;
void say(std::string_view line)
{
const std::lock_guard lock(output_mutex);
std::printf("%.*s\n", static_cast<int>(line.size()), line.data());
std::fflush(stdout);
}
// Lecture de la console dans un fil à part : la boucle principale reste libre pour
// surveiller l'exécutable du Core et la connexion.
class CommandReader {
public:
CommandReader()
{
std::thread([this] {
std::string line;
while (std::getline(std::cin, line)) {
const std::lock_guard lock(mutex_);
lines_.push_back(line);
}
const std::lock_guard lock(mutex_);
closed_ = true;
}).detach(); // getline n'est pas interruptible : le fil meurt avec le processus
}
std::optional<std::string> next()
{
const std::lock_guard lock(mutex_);
if (lines_.empty()) {
if (closed_) {
return std::string("quit");
}
return std::nullopt;
}
std::string line = std::move(lines_.front());
lines_.pop_front();
return line;
}
private:
std::mutex mutex_;
std::deque<std::string> lines_;
bool closed_ = false;
};
void print_reload(const ReloadReport& report, const char* what)
{
if (!report.ok) {
say(std::string(what) + " impossible : " + report.error);
return;
}
std::string line = std::string(what) + " : session reprise au tick " + std::to_string(report.tick) + " (rejeu " +
std::to_string(static_cast<long long>(report.replay_ms)) + " ms)";
if (report.compared) {
line += report.divergence ? ", DIVERGENCE au tick " + std::to_string(*report.divergence)
: ", comportement identique";
}
say(line + ".");
}
void print_status(StudioHost& host)
{
const auto core = host.request("core/status");
const auto session = host.request("session/status");
if (!core || !session) {
say("Core injoignable.");
return;
}
say("Core " + core.value->value("coreVersion", "?") + ", pid " + std::to_string(core.value->value("pid", 0)) +
", instance " + core.value->value("instanceId", "?") + ", " +
(core.value->value("linked", false) ? "lié" : "détaché") + ", clients " +
std::to_string(core.value->value("clients", 0)));
say("Session : tick " + std::to_string(session.value->value("tick", 0)) + ", " +
(session.value->value("running", false) ? "en marche" : "en pause") + ", empreinte " +
session.value->value("stateHash", "?") + ", entrées " + std::to_string(session.value->value("inputCount", 0)));
}
} // namespace
int main(int argc, char** argv)
{
#if defined(_WIN32)
SetConsoleOutputCP(CP_UTF8);
#endif
std::vector<std::string_view> args(argv + 1, argv + argc);
const ParsedOptions parsed = parse_options(args);
if (!parsed.options) {
std::fprintf(stderr, "%s\n%s", parsed.error.c_str(), usage().c_str());
return 2;
}
const HostOptions& options = *parsed.options;
if (options.help) {
std::printf("%s", usage().c_str());
return 0;
}
const auto exe_dir = current_exe_dir();
const auto core_exe = options.core_exe.empty() ? find_core_exe(exe_dir) : std::optional(options.core_exe);
if (!core_exe) {
std::fprintf(stderr, "Exécutable du Core introuvable : préciser --core-exe.\n");
return 2;
}
const auto run_dir = default_run_dir();
if (!run_dir) {
std::fprintf(stderr, "Dossier d'exécution introuvable (variable d'environnement manquante).\n");
return 1;
}
// Origines autorisées côté Core : l'interface servie ici, ou le serveur Vite.
const std::string ui_base = "http://127.0.0.1:" + std::to_string(options.ui_port);
HostConfig config;
config.core_exe = *core_exe;
config.run_dir = *run_dir;
config.core_port = options.core_port;
config.core_console = options.core_console;
config.log = say;
config.allowed_origins = {ui_base, "http://localhost:" + std::to_string(options.ui_port)};
if (options.dev_url) {
if (const auto origin = origin_of(*options.dev_url)) {
config.allowed_origins.push_back(*origin);
}
}
StudioHost host(config);
if (const std::string error = host.start(); !error.empty()) {
std::fprintf(stderr, "%s\n", error.c_str());
return 1;
}
if (options.detach_core) {
if (const std::string error = host.detach(); !error.empty()) {
say("Détachement impossible : " + error);
} else {
say("Bail levé : le Core survivra à Studio.");
}
}
// Interface : servie ici, ou par Vite en mode développement.
std::optional<UiServer> ui;
std::string page_base = ui_base;
if (options.dev_url) {
page_base = *options.dev_url;
while (!page_base.empty() && page_base.back() == '/') {
page_base.pop_back();
}
} else {
const auto ui_dir = options.ui_dir.empty() ? find_ui_dir(exe_dir) : std::optional(options.ui_dir);
if (!ui_dir) {
say("Interface introuvable (studio/ui/dist) : lancer « npm run build » dans studio/ui, ou utiliser --dev.");
} else {
UiServerOptions ui_options;
ui_options.port = options.ui_port;
ui_options.root = *ui_dir;
ui.emplace(ui_options);
if (const std::string error = ui->start(); !error.empty()) {
say("Interface non servie : " + error);
ui.reset();
}
}
}
const std::string page = build_ui_url(page_base, host.core_url());
say("Studio : " + page_base + " (Core ws://" + host.run().host + ":" + std::to_string(host.run().port) + ")");
if (!options.no_browser && (ui || options.dev_url) && !open_browser(page)) {
say("Navigateur non ouvert : ouvrir l'adresse à la main.");
}
say("Commandes : status, restart, reload, detach, attach, log, quit, help.");
std::signal(SIGINT, on_signal);
std::signal(SIGTERM, on_signal);
CommandReader commands;
FileWatcher watcher(*core_exe);
bool warned_disconnect = false;
for (;;) {
if (interrupted != 0) {
break;
}
if (watcher.poll()) {
if (options.watch) {
say("Nouvel exécutable du Core détecté : rechargement...");
print_reload(host.reload(), "Rechargement");
} else {
say("Nouvel exécutable du Core détecté : taper « reload » pour le charger en gardant la session.");
}
}
if (!host.connected() && !warned_disconnect) {
warned_disconnect = true;
say("Connexion au Core perdue. « restart » pour relancer (la session en mémoire est perdue), « log » pour le journal.");
}
const auto line = commands.next();
if (!line) {
std::this_thread::sleep_for(100ms);
continue;
}
const std::string& command = *line;
if (command.empty()) {
continue;
}
if (command == "quit" || command == "exit") {
break;
}
if (command == "help") {
say(usage());
} else if (command == "status") {
print_status(host);
} else if (command == "reload") {
print_reload(host.reload(), "Rechargement");
warned_disconnect = false;
} else if (command == "restart") {
if (host.connected()) {
print_reload(host.restart(), "Redémarrage");
} else {
const std::string error = host.start();
say(error.empty() ? "Core relancé (nouvelle session)." : "Relance impossible : " + error);
}
warned_disconnect = false;
} else if (command == "detach") {
const std::string error = host.detach();
say(error.empty() ? "Bail levé : le Core survivra à Studio." : "Détachement impossible : " + error);
} else if (command == "attach") {
const std::string error = host.attach();
say(error.empty() ? "Bail rétabli : le Core s'arrêtera avec Studio." : "Rattachement impossible : " + error);
} else if (command == "log") {
for (const std::string& entry : host.log_tail(20)) {
say(" " + entry);
}
} else {
say("Commande inconnue : " + command + " (help)");
}
}
if (ui) {
ui->stop();
}
host.shutdown();
return 0;
}
+178
View File
@@ -0,0 +1,178 @@
// SPDX-License-Identifier: MPL-2.0
#include "options.hpp"
#include <charconv>
#include <system_error>
#include <vector>
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#elif defined(__APPLE__)
#include <mach-o/dyld.h>
#else
#include <unistd.h>
#endif
namespace sequa::studio::host {
namespace {
#if defined(_WIN32)
constexpr const char* core_name = "sequa-core.exe";
#else
constexpr const char* core_name = "sequa-core";
#endif
std::optional<std::uint16_t> parse_port(std::string_view text)
{
unsigned value = 0;
const auto [next, ec] = std::from_chars(text.data(), text.data() + text.size(), value);
if (ec != std::errc{} || next != text.data() + text.size() || value == 0 || value > 65535) {
return std::nullopt;
}
return static_cast<std::uint16_t>(value);
}
bool is_file(const std::filesystem::path& path)
{
std::error_code ec;
return std::filesystem::is_regular_file(path, ec);
}
} // namespace
std::string usage()
{
return "Usage : sequa-studio [options]\n"
" --core-exe <chemin> exécutable du Core (défaut : trouvé à côté ou dans build/<preset>/core)\n"
" --core-port <n> port du Core (défaut 9100)\n"
" --ui-port <n> port de l'interface servie (défaut 9180)\n"
" --ui-dir <chemin> interface construite (défaut : studio/ui/dist)\n"
" --dev [url] n'ouvre que le serveur Vite (défaut http://localhost:5173)\n"
" --detach-core le Core survivra à Studio (bail levé)\n"
" --no-browser n'ouvre pas le navigateur\n"
" --core-console Core dans sa propre console (sortie non capturée)\n"
" --watch recharge le Core quand son exécutable change\n"
" --help\n"
"Commandes : status, restart, reload, detach, attach, log, quit, help\n";
}
ParsedOptions parse_options(std::span<const std::string_view> args)
{
HostOptions options;
for (std::size_t i = 0; i < args.size(); ++i) {
const std::string_view arg = args[i];
const bool has_value = i + 1 < args.size();
const auto fail = [](std::string message) { return ParsedOptions{std::nullopt, std::move(message)}; };
if (arg == "--help" || arg == "-h") {
options.help = true;
} else if (arg == "--detach-core") {
options.detach_core = true;
} else if (arg == "--no-browser") {
options.no_browser = true;
} else if (arg == "--core-console") {
options.core_console = true;
} else if (arg == "--watch") {
options.watch = true;
} else if (arg == "--dev") {
// Valeur facultative : prise seulement si elle ressemble à une URL.
if (has_value && (args[i + 1].starts_with("http://") || args[i + 1].starts_with("https://"))) {
options.dev_url = std::string(args[++i]);
} else {
options.dev_url = "http://localhost:5173";
}
} else if (arg == "--core-exe" && has_value) {
options.core_exe = std::filesystem::path(std::u8string(args[i + 1].begin(), args[i + 1].end()));
++i;
} else if (arg == "--ui-dir" && has_value) {
options.ui_dir = std::filesystem::path(std::u8string(args[i + 1].begin(), args[i + 1].end()));
++i;
} else if ((arg == "--core-port" || arg == "--ui-port") && has_value) {
const auto port = parse_port(args[++i]);
if (!port) {
return fail("Port invalide : " + std::string(args[i]));
}
(arg == "--core-port" ? options.core_port : options.ui_port) = *port;
} else {
return fail("Argument inconnu ou incomplet : " + std::string(arg));
}
}
if (options.core_port == options.ui_port) {
return {std::nullopt, "Le Core et l'interface ne peuvent pas partager le même port."};
}
return {options, {}};
}
std::optional<std::filesystem::path> find_core_exe(const std::filesystem::path& exe_dir)
{
if (is_file(exe_dir / core_name)) {
return exe_dir / core_name;
}
for (std::filesystem::path dir = exe_dir; dir.has_parent_path() && dir != dir.parent_path();
dir = dir.parent_path()) {
if (is_file(dir / "core" / core_name)) {
return dir / "core" / core_name;
}
}
return std::nullopt;
}
std::optional<std::filesystem::path> find_ui_dir(const std::filesystem::path& exe_dir)
{
for (std::filesystem::path dir = exe_dir; dir.has_parent_path() && dir != dir.parent_path();
dir = dir.parent_path()) {
const auto candidate = dir / "studio" / "ui" / "dist";
if (is_file(candidate / "index.html")) {
return candidate;
}
}
return std::nullopt;
}
std::optional<std::string> origin_of(std::string_view url)
{
std::size_t scheme_end = 0;
if (url.starts_with("http://")) {
scheme_end = 7;
} else if (url.starts_with("https://")) {
scheme_end = 8;
} else {
return std::nullopt;
}
const std::size_t path = url.find_first_of("/?#", scheme_end);
const std::string_view origin = url.substr(0, path);
if (origin.size() == scheme_end) {
return std::nullopt;
}
return std::string(origin);
}
std::filesystem::path current_exe_dir()
{
#if defined(_WIN32)
std::wstring buffer(MAX_PATH, L'\0');
for (;;) {
const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size()));
if (length < buffer.size()) {
buffer.resize(length);
break;
}
buffer.resize(buffer.size() * 2);
}
return std::filesystem::path(buffer).parent_path();
#elif defined(__APPLE__)
std::uint32_t size = 0;
_NSGetExecutablePath(nullptr, &size);
std::string buffer(size, '\0');
_NSGetExecutablePath(buffer.data(), &size);
return std::filesystem::weakly_canonical(buffer.c_str()).parent_path();
#else
std::error_code ec;
return std::filesystem::read_symlink("/proc/self/exe", ec).parent_path();
#endif
}
} // namespace sequa::studio::host
+48
View File
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <cstdint>
#include <filesystem>
#include <optional>
#include <span>
#include <string>
#include <string_view>
namespace sequa::studio::host {
// Options de sequa-studio (spec 0.2 §7.1).
struct HostOptions {
std::filesystem::path core_exe; // vide : recherche automatique
std::uint16_t core_port = 9100;
std::uint16_t ui_port = 9180;
std::filesystem::path ui_dir; // vide : recherche automatique
std::optional<std::string> dev_url; // --dev [url]
bool detach_core = false;
bool no_browser = false;
bool core_console = false;
bool watch = false;
bool help = false;
};
struct ParsedOptions {
std::optional<HostOptions> options;
std::string error; // non vide si options est vide
};
[[nodiscard]] ParsedOptions parse_options(std::span<const std::string_view> args);
[[nodiscard]] std::string usage();
// Exécutable du Core : à côté de sequa-studio, sinon <ancêtre>/core/sequa-core[.exe]
// en remontant depuis le dossier de sequa-studio (arborescence de build CMake).
[[nodiscard]] std::optional<std::filesystem::path> find_core_exe(const std::filesystem::path& exe_dir);
// Interface construite : <ancêtre>/studio/ui/dist contenant index.html.
[[nodiscard]] std::optional<std::filesystem::path> find_ui_dir(const std::filesystem::path& exe_dir);
// Origine (schéma://hôte:port) d'une URL http(s), pour --allowed-origin du Core.
[[nodiscard]] std::optional<std::string> origin_of(std::string_view url);
// Dossier de l'exécutable courant.
[[nodiscard]] std::filesystem::path current_exe_dir();
} // namespace sequa::studio::host
+308
View File
@@ -0,0 +1,308 @@
// SPDX-License-Identifier: MPL-2.0
#include "studio_host.hpp"
#include "sequa/studio/host/files.hpp"
#include "sequa/studio/host/paths.hpp"
#include "sequa/studio/host/random.hpp"
#include "sequa/studio/host/shadow.hpp"
#include "sequa/studio/host/time.hpp"
#include "sequa/studio/host/urls.hpp"
#include "sequa/studio/host/wire.hpp"
#include <thread>
#include <utility>
namespace sequa::studio::host {
namespace {
using namespace std::chrono_literals;
using nlohmann::json;
std::string describe(const Error& error)
{
return error.server_code.empty() ? error.message : error.server_code + " : " + error.message;
}
} // namespace
StudioHost::StudioHost(HostConfig config) : config_(std::move(config)) {}
StudioHost::~StudioHost()
{
if (client_) {
client_->close();
}
}
void StudioHost::say(const std::string& message) const
{
if (config_.log) {
config_.log(message);
}
}
bool StudioHost::connected() const
{
return client_ && client_->connected();
}
std::string StudioHost::core_url() const
{
return build_core_ws_url(run_.host, run_.port, run_.token);
}
RequestResult StudioHost::request(const std::string& type, const json& payload, std::chrono::milliseconds timeout)
{
if (!client_) {
return {std::nullopt, Error{ErrorCode::NotConnected, "Pas de connexion au Core.", {}, 0}};
}
return client_->request(type, payload, timeout);
}
std::vector<std::string> StudioHost::log_tail(std::size_t lines) const
{
return read_last_lines(log_file_path(config_.run_dir, config_.core_port), lines);
}
std::string StudioHost::connect_and_attach(const std::string& token, bool require_instance,
const std::string& expected_instance)
{
auto client = std::make_unique<CoreClient>();
const ConnectResult connection =
client->connect(build_core_ws_url(config_.host, config_.core_port, token), 2s);
if (!connection) {
return describe(*connection.error);
}
if (require_instance && connection.value->instance_id != expected_instance) {
return "instanceId différent : ce n'est pas le Core du fichier d'exécution";
}
const RequestResult attached = client->request("core/attach", json::object(), 5s);
if (!attached) {
return describe(*attached.error);
}
client_ = std::move(client);
linked_ = true;
run_.instanceId = connection.value->instance_id;
run_.pid = static_cast<std::uint32_t>(connection.value->pid);
return {};
}
std::string StudioHost::start()
{
if (!ensure_run_dirs(config_.run_dir)) {
return "Dossier d'exécution inaccessible : " + config_.run_dir.string();
}
const auto run_path = run_file_path(config_.run_dir, config_.core_port);
// 1. Rattachement à un Core existant (§7.2), validé par la poignée de main.
if (const auto existing = read_run_file(run_path); existing && existing->port == config_.core_port) {
run_ = *existing;
const std::string error = connect_and_attach(existing->token, true, existing->instanceId);
if (error.empty()) {
attached_existing_ = true;
say("Core existant rejoint (pid " + std::to_string(run_.pid) + ", instance " + run_.instanceId + ").");
return {};
}
if (error.starts_with("controller-exists")) {
return "Ce Core est déjà contrôlé par un autre Studio (port " + std::to_string(config_.core_port) + ").";
}
say("Fichier d'exécution périmé (" + error + ") : supprimé.");
(void)remove_run_file(run_path);
run_ = RunFile{};
}
// 2. Nouveau Core.
const auto token = random_hex(16);
if (!token) {
return "Générateur aléatoire indisponible.";
}
run_.token = *token;
return launch_core();
}
std::string StudioHost::launch_core()
{
const auto bin = bin_dir(config_.run_dir);
(void)cleanup_shadow_copies(bin);
const auto copy = make_shadow_copy(config_.core_exe, bin);
if (!copy) {
return "Copie de l'exécutable du Core impossible : " + config_.core_exe.string();
}
std::vector<std::string> args = {"--host", config_.host, "--port", std::to_string(config_.core_port),
"--token", run_.token, "--linked"};
for (const std::string& origin : config_.allowed_origins) {
args.insert(args.end(), {"--allowed-origin", origin});
}
LaunchOptions launch;
launch.own_console = config_.core_console;
launch.log_path = log_file_path(config_.run_dir, config_.core_port);
process_ = CoreProcess::launch(*copy, args, launch);
if (!process_) {
return "Lancement du Core impossible : " + copy->string();
}
// Attente du port puis poignée de main et bail.
const auto deadline = std::chrono::steady_clock::now() + config_.startup_timeout;
std::string last_error;
for (;;) {
last_error = connect_and_attach(run_.token, false, {});
if (last_error.empty()) {
break;
}
if (const auto code = process_->wait_for(0ms)) {
std::string tail;
for (const std::string& line : log_tail(5)) {
tail += "\n " + line;
}
process_.reset();
return "Le Core s'est arrêté au démarrage (code " + std::to_string(*code) + ")." + tail;
}
if (std::chrono::steady_clock::now() >= deadline) {
process_->terminate();
process_.reset();
return "Le Core ne répond pas : " + last_error;
}
std::this_thread::sleep_for(100ms);
}
run_.host = config_.host;
run_.port = config_.core_port;
run_.exePath = *copy;
run_.sourceExePath = config_.core_exe;
run_.startedAt = utc_now_iso8601();
attached_existing_ = false;
if (!write_run_file(run_file_path(config_.run_dir, config_.core_port), run_)) {
say("Attention : fichier d'exécution non écrit (rattachement ultérieur impossible).");
}
say("Core lancé (pid " + std::to_string(run_.pid) + ", instance " + run_.instanceId + ", " +
run_.exePath.filename().string() + ").");
return {};
}
void StudioHost::stop_core()
{
if (client_) {
(void)client_->request("core/shutdown", json::object(), 2s);
client_->close();
client_.reset();
}
if (process_) {
if (!process_->wait_for(3s)) {
say("Le Core ne s'arrête pas : arrêt forcé.");
process_->terminate();
}
process_.reset();
return;
}
// Core rejoint : pas de handle, on attend la disparition du PID (validé par instanceId).
for (int i = 0; i < 30 && is_process_alive(run_.pid); ++i) {
std::this_thread::sleep_for(100ms);
}
}
ReloadReport StudioHost::replace_core(bool compare)
{
ReloadReport report;
if (!client_) {
report.error = "Pas de connexion au Core.";
return report;
}
// 1. Exporter la session (enregistrement + empreintes) et noter marche/pause.
const RequestResult status = client_->request("session/status", json::object(), 5s);
const RequestResult exported = client_->request("session/export", json{{"includeHashes", true}}, 30s);
if (!status || !exported) {
report.error = "Export impossible : " + describe(status ? *exported.error : *status.error);
return report;
}
const bool running = status.value->value("running", true);
const std::string recording = exported.value->value("recording", "");
const auto old_hashes = decode_hashes(exported.value->value("hashes", ""));
if (!old_hashes) {
report.error = "Empreintes de l'ancien Core illisibles.";
return report;
}
// 2-3. Arrêter l'ancien Core, lancer le nouveau (mêmes port et jeton).
const bool was_linked = linked_;
stop_core();
if (const std::string error = launch_core(); !error.empty()) {
report.error = error;
return report;
}
// 4. Réimporter : rejeu jusqu'au même tick.
const RequestResult imported = client_->request(
"session/import", json{{"recording", recording}, {"resume", running}, {"includeHashes", true}}, 120s);
if (!imported) {
report.error = "Import impossible : " + describe(*imported.error);
return report;
}
report.tick = imported.value->value("tick", std::uint64_t{0});
report.replay_ms = imported.value->value("replayMs", 0.0);
// 5. Comparer les empreintes tick par tick.
if (compare) {
const auto new_hashes = decode_hashes(imported.value->value("hashes", ""));
if (!new_hashes) {
report.error = "Empreintes du nouveau Core illisibles.";
return report;
}
report.compared = true;
report.divergence = first_divergence(*old_hashes, *new_hashes);
}
if (!was_linked) {
(void)detach();
}
report.ok = true;
return report;
}
ReloadReport StudioHost::reload()
{
return replace_core(true);
}
ReloadReport StudioHost::restart()
{
return replace_core(false);
}
std::string StudioHost::detach()
{
const RequestResult result = request("core/detach");
if (!result) {
return describe(*result.error);
}
linked_ = false;
return {};
}
std::string StudioHost::attach()
{
const RequestResult result = request("core/attach");
if (!result) {
return describe(*result.error);
}
linked_ = true;
return {};
}
void StudioHost::shutdown()
{
if (!linked_) {
if (client_) {
client_->close();
client_.reset();
}
say("Core détaché : il continue (pid " + std::to_string(run_.pid) + ").");
return;
}
stop_core();
(void)remove_run_file(run_file_path(config_.run_dir, config_.core_port));
say("Core arrêté.");
}
} // namespace sequa::studio::host
+98
View File
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include "sequa/studio/host/client.hpp"
#include "sequa/studio/host/process.hpp"
#include "sequa/studio/host/run_file.hpp"
#include <nlohmann/json.hpp>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace sequa::studio::host {
struct HostConfig {
std::filesystem::path core_exe; // exécutable SOURCE du Core (jamais lancé directement)
std::filesystem::path run_dir; // dossier d'exécution (§7.4)
std::string host = "127.0.0.1";
std::uint16_t core_port = 9100;
std::vector<std::string> allowed_origins; // transmis au Core (--allowed-origin)
bool core_console = false;
std::chrono::milliseconds startup_timeout{10000};
std::function<void(std::string_view)> log; // messages pour l'utilisateur
};
// Résultat d'un rechargement ou d'un redémarrage (§7.6).
struct ReloadReport {
bool ok = false;
std::string error;
std::uint64_t tick = 0; // tick atteint par le rejeu
bool compared = false; // empreintes comparées (reload)
std::optional<std::size_t> divergence; // premier tick divergent ; vide = identique
double replay_ms = 0;
};
// Orchestrateur du Studio host : démarre ou rejoint le Core, le pilote par le protocole,
// le recharge par rejeu. Utilisé depuis un seul fil (la boucle de commandes).
class StudioHost {
public:
explicit StudioHost(HostConfig config);
~StudioHost(); // ferme la connexion ; le bail décide du sort du Core
StudioHost(const StudioHost&) = delete;
StudioHost& operator=(const StudioHost&) = delete;
// Rattachement via le fichier d'exécution s'il désigne un Core vivant (même
// instanceId), sinon lancement d'un nouveau Core. Chaîne vide si OK.
[[nodiscard]] std::string start();
[[nodiscard]] bool attached_existing() const noexcept { return attached_existing_; }
[[nodiscard]] bool linked() const noexcept { return linked_; }
[[nodiscard]] bool connected() const;
[[nodiscard]] const RunFile& run() const noexcept { return run_; }
// URL WebSocket du Core, jeton inclus (pour l'interface).
[[nodiscard]] std::string core_url() const;
// Rechargement avec l'exécutable source courant et comparaison des empreintes.
[[nodiscard]] ReloadReport reload();
// Redémarrage avec le même exécutable source, sans comparaison.
[[nodiscard]] ReloadReport restart();
[[nodiscard]] std::string detach(); // lève le bail : le Core survivra à Studio
[[nodiscard]] std::string attach(); // rétablit le bail
// Requête brute (commandes, tests).
[[nodiscard]] RequestResult request(const std::string& type, const nlohmann::json& payload = nlohmann::json::object(),
std::chrono::milliseconds timeout = std::chrono::seconds(5));
[[nodiscard]] std::vector<std::string> log_tail(std::size_t lines) const;
// Fin de Studio : Core lié -> core/shutdown, attente, fichier d'exécution supprimé ;
// Core détaché -> la connexion est seulement fermée, le Core et son fichier restent.
void shutdown();
private:
[[nodiscard]] std::string launch_core();
[[nodiscard]] std::string connect_and_attach(const std::string& token, bool require_instance,
const std::string& expected_instance);
void stop_core();
[[nodiscard]] ReloadReport replace_core(bool compare);
void say(const std::string& message) const;
HostConfig config_;
std::unique_ptr<CoreClient> client_;
std::optional<CoreProcess> process_; // vide si le Core a été rejoint (lancé par un autre Studio)
RunFile run_;
bool attached_existing_ = false;
bool linked_ = true;
};
} // namespace sequa::studio::host
+3
View File
@@ -0,0 +1,3 @@
// SPDX-License-Identifier: MPL-2.0
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
+99
View File
@@ -0,0 +1,99 @@
// SPDX-License-Identifier: MPL-2.0
#include "options.hpp"
#include <doctest/doctest.h>
#include <filesystem>
#include <fstream>
#include <string_view>
#include <vector>
using namespace sequa::studio::host;
namespace {
ParsedOptions parse(std::vector<std::string_view> args)
{
return parse_options(args);
}
} // namespace
TEST_CASE("Options : valeurs par defaut")
{
const auto parsed = parse({});
REQUIRE(parsed.options.has_value());
CHECK(parsed.options->core_port == 9100);
CHECK(parsed.options->ui_port == 9180);
CHECK_FALSE(parsed.options->dev_url.has_value());
CHECK_FALSE(parsed.options->watch);
}
TEST_CASE("Options : toutes les options")
{
const auto parsed = parse({"--core-exe", "C:/x/sequa-core.exe", "--core-port", "9200", "--ui-port", "9280",
"--dev", "http://localhost:5174", "--detach-core", "--no-browser", "--core-console",
"--watch", "--ui-dir", "dist"});
REQUIRE(parsed.options.has_value());
const auto& options = *parsed.options;
CHECK(options.core_exe == std::filesystem::path("C:/x/sequa-core.exe"));
CHECK(options.core_port == 9200);
CHECK(options.ui_port == 9280);
CHECK(options.dev_url == "http://localhost:5174");
CHECK(options.detach_core);
CHECK(options.no_browser);
CHECK(options.core_console);
CHECK(options.watch);
CHECK(options.ui_dir == std::filesystem::path("dist"));
}
TEST_CASE("Options : --dev sans URL")
{
const auto parsed = parse({"--dev", "--watch"});
REQUIRE(parsed.options.has_value());
CHECK(parsed.options->dev_url == "http://localhost:5173");
CHECK(parsed.options->watch);
}
TEST_CASE("Options : erreurs")
{
CHECK_FALSE(parse({"--core-port", "0"}).options.has_value());
CHECK_FALSE(parse({"--core-port", "70000"}).options.has_value());
CHECK_FALSE(parse({"--core-port", "91a"}).options.has_value());
CHECK_FALSE(parse({"--core-port"}).options.has_value());
CHECK_FALSE(parse({"--inconnue"}).options.has_value());
CHECK_FALSE(parse({"--core-port", "9180"}).options.has_value()); // même port que l'interface
}
TEST_CASE("Options : origine d'une URL")
{
CHECK(origin_of("http://localhost:5173/") == "http://localhost:5173");
CHECK(origin_of("https://exemple.com:8443/a?b#c") == "https://exemple.com:8443");
CHECK(origin_of("http://127.0.0.1:9180") == "http://127.0.0.1:9180");
CHECK_FALSE(origin_of("ws://127.0.0.1:9100").has_value());
CHECK_FALSE(origin_of("http://").has_value());
}
TEST_CASE("Options : recherche du Core et de l'interface dans une arborescence de build")
{
const auto root = std::filesystem::temp_directory_path() / "sequa-app-options-test";
std::filesystem::remove_all(root);
#if defined(_WIN32)
const char* core_name = "sequa-core.exe";
#else
const char* core_name = "sequa-core";
#endif
const auto exe_dir = root / "build" / "preset" / "studio" / "host" / "app";
std::filesystem::create_directories(exe_dir);
std::filesystem::create_directories(root / "build" / "preset" / "core");
std::filesystem::create_directories(root / "studio" / "ui" / "dist");
std::ofstream(root / "build" / "preset" / "core" / core_name) << "x";
std::ofstream(root / "studio" / "ui" / "dist" / "index.html") << "<html>";
CHECK(find_core_exe(exe_dir) == root / "build" / "preset" / "core" / core_name);
CHECK(find_ui_dir(exe_dir) == root / "studio" / "ui" / "dist");
std::ofstream(exe_dir / core_name) << "x"; // à côté : prioritaire (distribution)
CHECK(find_core_exe(exe_dir) == exe_dir / core_name);
std::filesystem::remove_all(root);
}
+165
View File
@@ -0,0 +1,165 @@
// SPDX-License-Identifier: MPL-2.0
//
// Bout en bout : StudioHost pilote le VRAI sequa-core (spec 0.2 §7, ADR 0006), dans un
// dossier d'exécution temporaire (jamais celui de l'utilisateur).
#include "studio_host.hpp"
#include "sequa/studio/host/paths.hpp"
#include "sequa/studio/host/random.hpp"
#include "sequa/studio/host/run_file.hpp"
#include <doctest/doctest.h>
#include <chrono>
#include <filesystem>
#include <memory>
#include <thread>
using namespace sequa::studio::host;
using namespace std::chrono_literals;
namespace {
const std::filesystem::path core_exe = SEQUA_TEST_CORE_EXE;
struct RunDir {
RunDir() : path(std::filesystem::temp_directory_path() / ("sequa-host-e2e-" + random_hex(6).value())) {}
~RunDir()
{
std::error_code ignored;
std::filesystem::remove_all(path, ignored);
}
std::filesystem::path path;
};
HostConfig config_for(const RunDir& dir, std::uint16_t port)
{
HostConfig config;
config.core_exe = core_exe;
config.run_dir = dir.path;
config.core_port = port;
config.allowed_origins = {"http://127.0.0.1:9180"};
return config;
}
// Démarre un host sur le premier port libre d'une plage réservée à ces tests.
std::unique_ptr<StudioHost> start_host(const RunDir& dir, std::uint16_t& port)
{
for (port = 19500; port < 19540; ++port) {
auto host = std::make_unique<StudioHost>(config_for(dir, port));
if (host->start().empty()) {
return host;
}
}
FAIL("aucun port libre entre 19500 et 19539");
return nullptr;
}
std::uint64_t tick_of(StudioHost& host)
{
const auto status = host.request("session/status");
REQUIRE(status.ok());
return status.value->value("tick", std::uint64_t{0});
}
bool wait_dead(std::uint32_t pid, std::chrono::milliseconds timeout)
{
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (is_process_alive(pid)) {
if (std::chrono::steady_clock::now() >= deadline) {
return false;
}
std::this_thread::sleep_for(50ms);
}
return true;
}
} // namespace
TEST_CASE("StudioHost : lancement puis rechargement par rejeu, session conservee")
{
RunDir dir;
std::uint16_t port = 0;
auto host = start_host(dir, port);
REQUIRE(host);
CHECK_FALSE(host->attached_existing());
CHECK(host->linked());
CHECK(host->core_url().find("?token=") != std::string::npos);
const auto first = host->run();
CHECK(read_run_file(run_file_path(dir.path, port)) == first);
CHECK(first.exePath.parent_path() == bin_dir(dir.path)); // lancé depuis une copie fantôme
// Un peu de jeu : courir et sauter pendant que la session tourne à 60 Hz.
REQUIRE(host->request("session/input", {{"buttons", 0}, {"moveX", 32767}, {"moveY", 0}}).ok());
std::this_thread::sleep_for(300ms);
REQUIRE(host->request("session/input", {{"buttons", 1}, {"moveX", 32767}, {"moveY", 0}}).ok());
std::this_thread::sleep_for(200ms);
const std::uint64_t before = tick_of(*host);
CHECK(before > 10);
const ReloadReport report = host->reload();
INFO("erreur : " << report.error);
REQUIRE(report.ok);
CHECK(report.compared);
CHECK_FALSE(report.divergence.has_value()); // même code : comportement identique
CHECK(report.tick >= before);
const auto second = host->run();
CHECK(second.pid != first.pid);
CHECK(second.instanceId != first.instanceId);
CHECK(second.token == first.token);
CHECK(wait_dead(first.pid, 3s)); // l'ancien Core est bien arrêté
CHECK(read_run_file(run_file_path(dir.path, port)) == second);
// La session continue dans le nouveau Core.
std::this_thread::sleep_for(200ms);
CHECK(tick_of(*host) > report.tick);
host->shutdown();
CHECK(wait_dead(second.pid, 3s));
CHECK_FALSE(std::filesystem::exists(run_file_path(dir.path, port)));
}
TEST_CASE("StudioHost : detachement reversible, rattachement par un nouveau Studio")
{
RunDir dir;
std::uint16_t port = 0;
auto first = start_host(dir, port);
REQUIRE(first);
const auto run = first->run();
REQUIRE(first->detach().empty());
CHECK_FALSE(first->linked());
first->shutdown(); // détaché : seule la connexion est fermée
first.reset();
std::this_thread::sleep_for(3500ms); // au-delà du délai de grâce du bail (3 s)
CHECK(is_process_alive(run.pid));
StudioHost second(config_for(dir, port));
REQUIRE(second.start().empty());
CHECK(second.attached_existing());
CHECK(second.run().instanceId == run.instanceId);
CHECK(second.linked()); // le rattachement rétablit le bail
second.shutdown();
CHECK(wait_dead(run.pid, 3s));
CHECK_FALSE(std::filesystem::exists(run_file_path(dir.path, port)));
}
TEST_CASE("StudioHost : Studio disparu sans arret propre -> le Core lie s'arrete seul")
{
RunDir dir;
std::uint16_t port = 0;
auto host = start_host(dir, port);
REQUIRE(host);
const auto pid = host->run().pid;
host.reset(); // comme un plantage : aucune demande d'arrêt
CHECK(wait_dead(pid, 6s)); // délai de grâce 3 s, puis arrêt (code 3)
// Le fichier d'exécution est resté : le Studio suivant le détecte périmé.
REQUIRE(std::filesystem::exists(run_file_path(dir.path, port)));
StudioHost next(config_for(dir, port));
REQUIRE(next.start().empty());
CHECK_FALSE(next.attached_existing());
next.shutdown();
}
+1 -1
View File
@@ -6,7 +6,7 @@
* Le format des messages reste provisoire (voir `src/protocol/messages.ts`) ;
* seule la règle de compatibilité par majeure est stable.
*/
export const PROTOCOL_VERSION = '0.1.0';
export const PROTOCOL_VERSION = '0.2.0';
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
+10 -1
View File
@@ -44,6 +44,15 @@ function createBrowserSocket(url: string): WebSocketLike {
return new WebSocket(url) as unknown as WebSocketLike;
}
/**
* URL affichable : sans la partie requête, qui porte le jeton du Core
* (`?token=...`, spec 0.2 §3). Le jeton ne doit jamais apparaître dans l'interface.
*/
function withoutQuery(url: string): string {
const query = url.indexOf('?');
return query === -1 ? url : url.slice(0, query);
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -84,7 +93,7 @@ export class WebSocketCoreConnection implements CoreConnection {
public constructor(url: string, options: WebSocketCoreConnectionOptions = {}) {
this.url = url;
this.label = `Core ${url}`;
this.label = `Core ${withoutQuery(url)}`;
this.createSocket = options.createSocket ?? createBrowserSocket;
this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
}
+5 -5
View File
@@ -140,15 +140,15 @@ describe('decode', () => {
describe('isCompatibleProtocolVersion', () => {
it('accepte la même majeure et la même mineure en 0.x', () => {
expect(isCompatibleProtocolVersion('0.1.0')).toBe(true);
expect(isCompatibleProtocolVersion('0.1.5')).toBe(true);
expect(isCompatibleProtocolVersion('0.1.0-alpha.1')).toBe(true);
expect(isCompatibleProtocolVersion('0.2.0')).toBe(true);
expect(isCompatibleProtocolVersion('0.2.5')).toBe(true);
expect(isCompatibleProtocolVersion('0.2.0-alpha.1')).toBe(true);
});
it('refuse une autre mineure tant que la majeure vaut 0', () => {
expect(isCompatibleProtocolVersion('0.2.0')).toBe(false);
expect(isCompatibleProtocolVersion('0.1.0')).toBe(false);
expect(isCompatibleProtocolVersion('0.9.2')).toBe(false);
expect(isCompatibleProtocolVersion('0.2.0-alpha.1')).toBe(false);
expect(isCompatibleProtocolVersion('0.3.0-alpha.1')).toBe(false);
});
it('refuse une autre majeure', () => {
+3
View File
@@ -155,6 +155,9 @@ describe('WebSocketCoreConnection', () => {
expect(connection.simulated).toBe(false);
expect(connection.label).toBe(`Core ${CORE_URL}`);
// Le jeton du Core n'apparaît jamais dans le libellé affiché.
const withToken = new WebSocketCoreConnection(`${CORE_URL}?token=secret123`);
expect(withToken.label).toBe(`Core ${CORE_URL}`);
});
it('émet connecting, envoie hello puis émet connected après welcome', () => {