feat(studio/host): brique client, protocole 0.2 côté host
CoreClient (IXWebSocket) : connexion + hello sequa-studio-host, requêtes corrélées par id avec délais, requêtes concurrentes, erreur immédiate à la déconnexion, rappel on_disconnect. wire : hex64, base64 RFC 4648 strict, décodage des empreintes, first_divergence. 15 cas (Debug, ASan) contre un faux serveur conforme à la spec. Écrit par un subagent (DeepSeek V4.1 Flash), relu.
This commit is contained in:
1 parent
99b0a0f008
commit
ea9f5709fa
9 files changed
+1579
No files matched your search
@@ -0,0 +1,34 @@
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
#
|
||||
# Brique client du Studio host : client C++ du protocole Studio - Core 0.2
|
||||
# (docs/specs/protocole-studio-core-0.2.md §2, §3, §4 ; ADR 0006).
|
||||
# Rôle, API et licences : voir README.md.
|
||||
|
||||
add_library(sequa_host_client
|
||||
src/client.cpp
|
||||
src/wire.cpp)
|
||||
add_library(sequa::host_client ALIAS sequa_host_client)
|
||||
|
||||
target_include_directories(sequa_host_client
|
||||
PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include")
|
||||
target_compile_features(sequa_host_client PUBLIC cxx_std_20)
|
||||
|
||||
# nlohmann_json est PUBLIC : les charges utiles de l'API sont des nlohmann::json.
|
||||
# ixwebsocket reste PRIVÉ : le transport ne fuit pas dans l'interface publique.
|
||||
target_link_libraries(sequa_host_client
|
||||
PUBLIC nlohmann_json::nlohmann_json
|
||||
PRIVATE ixwebsocket::ixwebsocket)
|
||||
|
||||
sequa_target_defaults(sequa_host_client)
|
||||
|
||||
# --- Tests : faux serveur WebSocket local, sans dépendre de sequa_core_server -------------
|
||||
if(SEQUA_BUILD_TESTS)
|
||||
add_executable(sequa_host_client_tests
|
||||
tests/main.cpp
|
||||
tests/test_wire.cpp
|
||||
tests/test_client.cpp)
|
||||
target_link_libraries(sequa_host_client_tests
|
||||
PRIVATE sequa::host_client doctest::doctest ixwebsocket::ixwebsocket)
|
||||
sequa_target_defaults(sequa_host_client_tests)
|
||||
doctest_discover_tests(sequa_host_client_tests)
|
||||
endif()
|
||||
@@ -0,0 +1,69 @@
|
||||
# Studio host — brique `client`
|
||||
|
||||
Client C++ du **protocole Studio - Core 0.2** côté Studio host
|
||||
(`docs/specs/protocole-studio-core-0.2.md` §2, §3, §4 ; ADR 0006).
|
||||
|
||||
Bibliothèque statique `sequa_host_client` (alias `sequa::host_client`), en-têtes publics dans
|
||||
`include/sequa/studio/host/`, espace de noms `sequa::studio::host`. Aucune exception ne remonte
|
||||
à l'appelant : les opérations renvoient un type résultat (`Result<T>`) portant soit une valeur,
|
||||
soit une `Error` (code + message).
|
||||
|
||||
## Rôle
|
||||
|
||||
L'hôte n'accède au Core que par ce client :
|
||||
|
||||
- se connecter (`hello` / `welcome`), s'attacher ou se détacher (`core/attach`, `core/detach`) ;
|
||||
- piloter la session (`session/start`, `session/pause`, `session/resume`, `session/input`,
|
||||
`session/status`) ;
|
||||
- exporter/importer un enregistrement et **comparer les empreintes** tick par tick lors du
|
||||
rechargement par rejeu (§7.6).
|
||||
|
||||
Il ne fait **pas** : lancer le Core, gérer le processus, le fichier d'exécution, ni le service
|
||||
HTTP (autres briques de `studio/host/`).
|
||||
|
||||
## API publique
|
||||
|
||||
`wire.hpp` — représentations du protocole (§2) :
|
||||
|
||||
- `std::string to_hex64(std::uint64_t)` / `std::optional<std::uint64_t> from_hex64(std::string_view)` :
|
||||
entiers 64 bits en 16 caractères hexadécimaux minuscules ; analyse stricte.
|
||||
- `std::string base64_encode(std::span<const std::uint8_t>)` / `(std::string_view)` et
|
||||
`std::optional<std::vector<std::uint8_t>> base64_decode(std::string_view)` : base64 standard
|
||||
RFC 4648, décodage strict.
|
||||
- `std::optional<std::vector<std::uint64_t>> decode_hashes(std::string_view)` : base64 -> tableau
|
||||
d'empreintes `uint64` petit-boutiste.
|
||||
- `std::optional<std::size_t> first_divergence(hashesA, hashesB)` : premier tick divergent, ou
|
||||
`nullopt` si identiques.
|
||||
|
||||
`client.hpp` — client :
|
||||
|
||||
```cpp
|
||||
struct Welcome { std::string protocol_version, core_version, instance_id;
|
||||
std::int64_t pid; bool linked; };
|
||||
|
||||
ConnectResult connect(const std::string& url, std::chrono::milliseconds timeout);
|
||||
RequestResult request(const std::string& type, const nlohmann::json& payload,
|
||||
std::chrono::milliseconds timeout);
|
||||
bool connected() const noexcept;
|
||||
void close();
|
||||
void on_disconnect(std::function<void(std::uint16_t, const std::string&)> callback);
|
||||
std::uint64_t ignored_message_count() const noexcept;
|
||||
```
|
||||
|
||||
`request()` est sans état partagé : plusieurs fils peuvent l'appeler en parallèle, chacun avec
|
||||
un `id` unique et reçoit **sa** réponse (les messages sans `id` ou d'`id` inconnu sont ignorés
|
||||
et comptés). Les délais dépassés et les déconnexions sont signalés par `Error::code`
|
||||
(`Timeout`, `ConnectFailed`, `ServerError`, `Closed`, `MalformedResponse`,
|
||||
`IncompatibleVersion`, `NotConnected`).
|
||||
|
||||
## Dépendances
|
||||
|
||||
- **IXWebSocket** — BSD-3-Clause (lien PRIVÉ : non exposé dans l'interface publique).
|
||||
- **nlohmann/json** — MIT (lien PUBLIC : les charges utiles de l'API sont des `nlohmann::json`).
|
||||
- Le code de cette brique et les tests sont sous **MPL 2.0**.
|
||||
|
||||
## Tests
|
||||
|
||||
`sequa_host_client_tests` (doctest) s'exécute contre un **faux serveur WebSocket minimal** qui
|
||||
suit la spec (welcome, réponses avec `id` recopié, `error`, requête lente, fermeture
|
||||
volontaire). Il ne dépend pas de `sequa_core_server`.
|
||||
@@ -0,0 +1,120 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
// Client du protocole Studio - Core 0.2 côté Studio host
|
||||
// (docs/specs/protocole-studio-core-0.2.md §2, §3, §4 ; ADR 0006).
|
||||
//
|
||||
// Le client ne lève jamais d'exception vers l'appelant : connect() et request() renvoient un
|
||||
// type résultat (Result<T>) portant soit une valeur, soit une Error (code + message).
|
||||
namespace sequa::studio::host {
|
||||
|
||||
// Version du protocole parlée par ce client et nom de client annoncé dans `hello` (spec §2, §3).
|
||||
inline constexpr std::string_view protocol_version = "0.2.0";
|
||||
inline constexpr std::string_view host_client_name = "sequa-studio-host";
|
||||
|
||||
// Charge utile de la réponse `welcome` (spec §3).
|
||||
struct Welcome {
|
||||
std::string protocol_version; // `protocolVersion` du Core
|
||||
std::string core_version; // `coreVersion`
|
||||
std::string instance_id; // `instanceId` : 32 caractères hexadécimaux
|
||||
std::int64_t pid = 0; // `pid` du processus Core
|
||||
bool linked = false; // `linked` : état du bail (§5)
|
||||
};
|
||||
|
||||
// Catégorie d'erreur renvoyée par le client.
|
||||
enum class ErrorCode {
|
||||
NotConnected, // aucune connexion ouverte au moment de la requête
|
||||
Timeout, // délai dépassé (connexion ou réponse)
|
||||
ConnectFailed, // connexion réseau impossible (hôte/port injoignable)
|
||||
ServerError, // le Core a répondu un message `error` (voir Error::server_code)
|
||||
Closed, // connexion fermée (voir Error::close_code)
|
||||
MalformedResponse, // réponse illisible ou inattendue
|
||||
IncompatibleVersion // version du protocole incompatible (règle 0.x, spec §2)
|
||||
};
|
||||
|
||||
[[nodiscard]] std::string_view to_string(ErrorCode code) noexcept;
|
||||
|
||||
// Erreur détaillée. `server_code` n'est renseigné que pour ErrorCode::ServerError,
|
||||
// `close_code` que pour ErrorCode::Closed.
|
||||
struct Error {
|
||||
ErrorCode code = ErrorCode::NotConnected;
|
||||
std::string message;
|
||||
std::string server_code;
|
||||
std::uint16_t close_code = 0;
|
||||
};
|
||||
|
||||
// Résultat d'une opération : soit une valeur, soit une erreur.
|
||||
template <typename T>
|
||||
struct Result {
|
||||
std::optional<T> value;
|
||||
std::optional<Error> error;
|
||||
|
||||
[[nodiscard]] bool ok() const noexcept { return value.has_value(); }
|
||||
explicit operator bool() const noexcept { return ok(); }
|
||||
};
|
||||
|
||||
using ConnectResult = Result<Welcome>;
|
||||
using RequestResult = Result<nlohmann::json>;
|
||||
|
||||
// Règle de compatibilité 0.x (spec §2) : même majeure et, tant que la majeure vaut 0,
|
||||
// même mineure. Une version illisible est incompatible.
|
||||
[[nodiscard]] bool is_compatible_protocol_version(std::string_view remote) noexcept;
|
||||
|
||||
// Client WebSocket du protocole Studio - Core. Une instance = une connexion.
|
||||
//
|
||||
// Thread-safety : request() et connected() peuvent être appelés depuis plusieurs fils ;
|
||||
// chaque requête reçoit la réponse portant SON id. connect()/close() ne sont pas destinés à
|
||||
// être appelés depuis plusieurs fils à la fois.
|
||||
class CoreClient {
|
||||
public:
|
||||
// Rappel de déconnexion : appelé au plus une fois par déconnexion, avec le code et la
|
||||
// raison de fermeture (code 0 si la connexion n'a jamais abouti).
|
||||
using DisconnectCallback = std::function<void(std::uint16_t code, const std::string& reason)>;
|
||||
|
||||
CoreClient();
|
||||
~CoreClient();
|
||||
|
||||
CoreClient(const CoreClient&) = delete;
|
||||
CoreClient& operator=(const CoreClient&) = delete;
|
||||
CoreClient(CoreClient&&) = delete;
|
||||
CoreClient& operator=(CoreClient&&) = delete;
|
||||
|
||||
// Ouvre `url` (URL complète, jeton inclus), envoie `hello` et attend `welcome`.
|
||||
// Sans reconnexion automatique ni per-message deflate.
|
||||
[[nodiscard]] ConnectResult connect(const std::string& url, std::chrono::milliseconds timeout);
|
||||
|
||||
// Envoie une requête typée avec un id unique et attend la réponse portant le même id.
|
||||
// Renvoie la charge utile si le type de réponse correspond, sinon une Error.
|
||||
// Une déconnexion pendant l'attente est signalée immédiatement (sans attendre le délai).
|
||||
[[nodiscard]] RequestResult request(const std::string& type, const nlohmann::json& payload,
|
||||
std::chrono::milliseconds timeout);
|
||||
|
||||
[[nodiscard]] bool connected() const noexcept;
|
||||
|
||||
// Ferme la connexion. Idempotent ; sans effet si aucune connexion n'a été ouverte.
|
||||
void close();
|
||||
|
||||
// Définit (ou remplace) le rappel de déconnexion. Il s'exécute sur le fil interne
|
||||
// d'IXWebSocket : il ne doit ni appeler close(), ni détruire le client, ni bloquer
|
||||
// (ce fil attendrait sa propre fin). Signaler l'événement et le traiter ailleurs.
|
||||
void on_disconnect(DisconnectCallback callback);
|
||||
|
||||
// Diagnostic : nombre de messages reçus sans id, d'id inconnu, ou illisibles.
|
||||
[[nodiscard]] std::uint64_t ignored_message_count() const noexcept;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace sequa::studio::host
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// Petites aides de représentation du protocole Studio - Core 0.2
|
||||
// (docs/specs/protocole-studio-core-0.2.md §2) : entiers 64 bits en hexadécimal,
|
||||
// données binaires en base64 standard, comparaison d'empreintes (§7.6).
|
||||
namespace sequa::studio::host {
|
||||
|
||||
// --- Entiers 64 bits (spec §2) ----------------------------------------------------------
|
||||
// Les entiers 64 bits ne sont jamais transmis en nombre JSON (perte de précision au-delà de
|
||||
// 2^53 en JavaScript) mais en chaîne hexadécimale minuscule de 16 caractères.
|
||||
|
||||
inline constexpr std::size_t hex64_length = 16;
|
||||
|
||||
// Formate en 16 caractères hexadécimaux minuscules (complétés par des zéros à gauche).
|
||||
[[nodiscard]] std::string to_hex64(std::uint64_t value);
|
||||
|
||||
// Analyse stricte : exactement 16 caractères hexadécimaux MINUSCULES, sinon nullopt.
|
||||
[[nodiscard]] std::optional<std::uint64_t> from_hex64(std::string_view text) noexcept;
|
||||
|
||||
// --- Base64 standard, RFC 4648, avec remplissage '=' (spec §2) --------------------------
|
||||
// Encodage des octets bruts ou d'un texte ; décodage strict (alphabet, longueur, remplissage
|
||||
// et bits de queue nuls).
|
||||
[[nodiscard]] std::string base64_encode(std::span<const std::uint8_t> bytes);
|
||||
[[nodiscard]] std::string base64_encode(std::string_view text);
|
||||
[[nodiscard]] std::optional<std::vector<std::uint8_t>> base64_decode(std::string_view text);
|
||||
|
||||
// --- Empreintes (spec §4 session/export, §7.6 rechargement) -----------------------------
|
||||
// Tableau d'empreintes décodées : chaque empreinte est un uint64 transmis en petit-boutiste
|
||||
// (8 octets) ; la longueur du bloc doit être un multiple de 8.
|
||||
[[nodiscard]] std::optional<std::vector<std::uint64_t>> decode_hashes(std::string_view base64);
|
||||
|
||||
// Compare deux suites d'empreintes tick par tick. Renvoie l'indice du premier tick divergent,
|
||||
// ou nullopt si elles sont identiques (y compris même longueur et mêmes valeurs). Si l'une est
|
||||
// un préfixe de l'autre, l'indice renvoyé est la longueur de la plus courte.
|
||||
[[nodiscard]] std::optional<std::size_t> first_divergence(
|
||||
std::span<const std::uint64_t> expected, std::span<const std::uint64_t> actual) noexcept;
|
||||
|
||||
} // namespace sequa::studio::host
|
||||
@@ -0,0 +1,573 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
#include "sequa/studio/host/client.hpp"
|
||||
|
||||
#include <ixwebsocket/IXNetSystem.h>
|
||||
#include <ixwebsocket/IXWebSocket.h>
|
||||
#include <ixwebsocket/IXWebSocketCloseConstants.h>
|
||||
|
||||
#include <condition_variable>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace sequa::studio::host {
|
||||
namespace {
|
||||
|
||||
// Version de l'enveloppe (spec §2 : `v` reste 1).
|
||||
constexpr int envelope_version = 1;
|
||||
|
||||
// Extrait (majeure, mineure) d'une version "x.y.z" ; renvoie false si illisible.
|
||||
bool parse_major_minor(std::string_view text, int& major, int& minor)
|
||||
{
|
||||
const std::size_t first = text.find('.');
|
||||
if (first == std::string_view::npos || first == 0U) {
|
||||
return false;
|
||||
}
|
||||
const std::size_t second = text.find('.', first + 1U);
|
||||
if (second == std::string_view::npos || second == first + 1U) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto to_int = [](std::string_view digits, int& out) {
|
||||
if (digits.empty()) {
|
||||
return false;
|
||||
}
|
||||
int value = 0;
|
||||
for (const char c : digits) {
|
||||
if (c < '0' || c > '9') {
|
||||
return false;
|
||||
}
|
||||
value = value * 10 + (c - '0');
|
||||
}
|
||||
out = value;
|
||||
return true;
|
||||
};
|
||||
return to_int(text.substr(0U, first), major) &&
|
||||
to_int(text.substr(first + 1U, second - first - 1U), minor);
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
Result<T> failure(ErrorCode code, std::string message, std::string server_code = {},
|
||||
std::uint16_t close_code = 0)
|
||||
{
|
||||
Result<T> result;
|
||||
Error error;
|
||||
error.code = code;
|
||||
error.message = std::move(message);
|
||||
error.server_code = std::move(server_code);
|
||||
error.close_code = close_code;
|
||||
result.error = std::move(error);
|
||||
return result;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
Result<T> success(T value)
|
||||
{
|
||||
Result<T> result;
|
||||
result.value = std::move(value);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string_view to_string(ErrorCode code) noexcept
|
||||
{
|
||||
switch (code) {
|
||||
case ErrorCode::NotConnected:
|
||||
return "not-connected";
|
||||
case ErrorCode::Timeout:
|
||||
return "timeout";
|
||||
case ErrorCode::ConnectFailed:
|
||||
return "connect-failed";
|
||||
case ErrorCode::ServerError:
|
||||
return "server-error";
|
||||
case ErrorCode::Closed:
|
||||
return "closed";
|
||||
case ErrorCode::MalformedResponse:
|
||||
return "malformed-response";
|
||||
case ErrorCode::IncompatibleVersion:
|
||||
return "incompatible-version";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
bool is_compatible_protocol_version(std::string_view remote) noexcept
|
||||
{
|
||||
int remote_major = 0;
|
||||
int remote_minor = 0;
|
||||
int local_major = 0;
|
||||
int local_minor = 0;
|
||||
if (!parse_major_minor(remote, remote_major, remote_minor) ||
|
||||
!parse_major_minor(protocol_version, local_major, local_minor)) {
|
||||
return false;
|
||||
}
|
||||
if (remote_major != local_major) {
|
||||
return false;
|
||||
}
|
||||
// Tant que la majeure vaut 0 (API instable), la mineure doit aussi correspondre.
|
||||
return local_major != 0 || remote_minor == local_minor;
|
||||
}
|
||||
|
||||
struct CoreClient::Impl {
|
||||
// Attente d'UNE réponse (requête identifiée ou message de poignée de main).
|
||||
struct Inbox {
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
bool done = false;
|
||||
bool disconnected = false;
|
||||
ErrorCode disconnect_kind = ErrorCode::Closed;
|
||||
std::uint16_t close_code = 0;
|
||||
std::string close_reason;
|
||||
bool malformed = false;
|
||||
bool is_error = false;
|
||||
std::string error_code;
|
||||
std::string error_message;
|
||||
std::string response_type;
|
||||
nlohmann::json response_payload;
|
||||
};
|
||||
|
||||
Impl() = default;
|
||||
~Impl()
|
||||
{
|
||||
shutdown_socket();
|
||||
uninit_net();
|
||||
}
|
||||
|
||||
Impl(const Impl&) = delete;
|
||||
Impl& operator=(const Impl&) = delete;
|
||||
|
||||
// --- Transport ---------------------------------------------------------------------
|
||||
std::unique_ptr<ix::WebSocket> socket;
|
||||
bool net_initialized = false;
|
||||
|
||||
// --- État de connexion (protégé par mutex) -----------------------------------------
|
||||
mutable std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
bool open = false;
|
||||
bool closed = false;
|
||||
ErrorCode disconnect_kind = ErrorCode::Closed;
|
||||
std::uint16_t close_code = 0;
|
||||
std::string close_reason;
|
||||
bool disconnect_notified = false;
|
||||
DisconnectCallback disconnect_callback;
|
||||
|
||||
// --- Corrélation des requêtes (protégé par mutex) ----------------------------------
|
||||
std::uint64_t next_id = 1;
|
||||
std::map<std::string, std::shared_ptr<Inbox>> pending;
|
||||
std::shared_ptr<Inbox> handshake;
|
||||
std::uint64_t ignored = 0;
|
||||
|
||||
void init_net()
|
||||
{
|
||||
if (!net_initialized) {
|
||||
net_initialized = ix::initNetSystem();
|
||||
}
|
||||
}
|
||||
|
||||
void uninit_net()
|
||||
{
|
||||
if (net_initialized) {
|
||||
ix::uninitNetSystem();
|
||||
net_initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
void count_ignored()
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
++ignored;
|
||||
}
|
||||
|
||||
void clear_handshake()
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
handshake.reset();
|
||||
}
|
||||
|
||||
void on_message(const ix::WebSocketMessagePtr& message)
|
||||
{
|
||||
switch (message->type) {
|
||||
case ix::WebSocketMessageType::Open: {
|
||||
std::lock_guard lock(mutex);
|
||||
open = true;
|
||||
cv.notify_all();
|
||||
return;
|
||||
}
|
||||
case ix::WebSocketMessageType::Message:
|
||||
handle_text(message->str);
|
||||
return;
|
||||
case ix::WebSocketMessageType::Close:
|
||||
handle_disconnect(ErrorCode::Closed, message->closeInfo.code, message->closeInfo.reason);
|
||||
return;
|
||||
case ix::WebSocketMessageType::Error: {
|
||||
bool was_open = false;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
was_open = open && !closed;
|
||||
}
|
||||
// Erreur avant ouverture : connexion impossible ; après : déconnexion.
|
||||
handle_disconnect(was_open ? ErrorCode::Closed : ErrorCode::ConnectFailed, 0,
|
||||
message->errorInfo.reason);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void handle_text(const std::string& text)
|
||||
{
|
||||
nlohmann::json envelope;
|
||||
try {
|
||||
envelope = nlohmann::json::parse(text);
|
||||
} catch (const nlohmann::json::exception&) {
|
||||
count_ignored();
|
||||
return;
|
||||
}
|
||||
if (!envelope.is_object()) {
|
||||
count_ignored();
|
||||
return;
|
||||
}
|
||||
|
||||
std::string id;
|
||||
if (auto it = envelope.find("id"); it != envelope.end() && it->is_string()) {
|
||||
id = it->get<std::string>();
|
||||
}
|
||||
|
||||
// Routage : par id (requête) ou vers la poignée de main (message sans id).
|
||||
std::shared_ptr<Inbox> target;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
if (!id.empty()) {
|
||||
if (auto it = pending.find(id); it != pending.end()) {
|
||||
target = it->second;
|
||||
}
|
||||
} else if (handshake) {
|
||||
target = handshake;
|
||||
}
|
||||
if (!target) {
|
||||
++ignored; // message sans id ou d'id inconnu : ignoré
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const bool has_type = envelope.contains("type") && envelope["type"].is_string();
|
||||
const std::string type = has_type ? envelope["type"].get<std::string>() : std::string();
|
||||
const bool has_payload = envelope.contains("payload") && envelope["payload"].is_object();
|
||||
const nlohmann::json payload =
|
||||
has_payload ? envelope["payload"] : nlohmann::json::object();
|
||||
|
||||
{
|
||||
std::lock_guard lock(target->mutex);
|
||||
if (!has_type || !has_payload) {
|
||||
target->malformed = true;
|
||||
} else if (type == "error") {
|
||||
target->is_error = true;
|
||||
target->response_type = type;
|
||||
target->error_code = payload.value("code", std::string());
|
||||
target->error_message = payload.value("message", std::string());
|
||||
} else {
|
||||
target->response_type = type;
|
||||
target->response_payload = payload;
|
||||
}
|
||||
target->done = true;
|
||||
}
|
||||
target->cv.notify_all();
|
||||
}
|
||||
|
||||
void handle_disconnect(ErrorCode kind, std::uint16_t code, std::string reason)
|
||||
{
|
||||
std::vector<std::shared_ptr<Inbox>> to_notify;
|
||||
DisconnectCallback callback;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
if (closed) {
|
||||
return; // déjà traité : le rappel n'est appelé qu'une fois
|
||||
}
|
||||
closed = true;
|
||||
open = false;
|
||||
disconnect_kind = kind;
|
||||
close_code = code;
|
||||
close_reason = reason;
|
||||
for (auto& entry : pending) {
|
||||
to_notify.push_back(entry.second);
|
||||
}
|
||||
pending.clear();
|
||||
if (handshake) {
|
||||
to_notify.push_back(handshake);
|
||||
handshake.reset();
|
||||
}
|
||||
if (!disconnect_notified) {
|
||||
disconnect_notified = true;
|
||||
callback = disconnect_callback;
|
||||
}
|
||||
}
|
||||
cv.notify_all();
|
||||
for (const std::shared_ptr<Inbox>& inbox : to_notify) {
|
||||
{
|
||||
std::lock_guard lock(inbox->mutex);
|
||||
if (inbox->done) {
|
||||
continue;
|
||||
}
|
||||
inbox->done = true;
|
||||
inbox->disconnected = true;
|
||||
inbox->disconnect_kind = kind;
|
||||
inbox->close_code = code;
|
||||
inbox->close_reason = reason;
|
||||
}
|
||||
inbox->cv.notify_all();
|
||||
}
|
||||
if (callback) {
|
||||
callback(code, reason);
|
||||
}
|
||||
}
|
||||
|
||||
void shutdown_socket()
|
||||
{
|
||||
std::unique_ptr<ix::WebSocket> local;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
local = std::move(socket);
|
||||
}
|
||||
if (!local) {
|
||||
return; // aucune connexion n'a été ouverte
|
||||
}
|
||||
local->stop();
|
||||
handle_disconnect(ErrorCode::Closed, ix::WebSocketCloseConstants::kNormalClosureCode,
|
||||
std::string{});
|
||||
}
|
||||
};
|
||||
|
||||
CoreClient::CoreClient() : impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
CoreClient::~CoreClient() = default;
|
||||
|
||||
ConnectResult CoreClient::connect(const std::string& url, std::chrono::milliseconds timeout)
|
||||
{
|
||||
Impl& self = *impl_;
|
||||
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||
|
||||
self.shutdown_socket();
|
||||
{
|
||||
std::lock_guard lock(self.mutex);
|
||||
self.open = false;
|
||||
self.closed = false;
|
||||
self.disconnect_kind = ErrorCode::Closed;
|
||||
self.close_code = 0;
|
||||
self.close_reason.clear();
|
||||
self.disconnect_notified = false;
|
||||
}
|
||||
|
||||
self.init_net();
|
||||
if (!self.net_initialized) {
|
||||
return failure<Welcome>(ErrorCode::ConnectFailed,
|
||||
"Initialisation du sous-système réseau impossible.");
|
||||
}
|
||||
|
||||
auto handshake = std::make_shared<Impl::Inbox>();
|
||||
{
|
||||
std::lock_guard lock(self.mutex);
|
||||
self.handshake = handshake;
|
||||
}
|
||||
|
||||
auto socket = std::make_unique<ix::WebSocket>();
|
||||
socket->setUrl(url);
|
||||
socket->disableAutomaticReconnection();
|
||||
socket->disablePerMessageDeflate();
|
||||
socket->setOnMessageCallback(
|
||||
[&self](const ix::WebSocketMessagePtr& message) { self.on_message(message); });
|
||||
self.socket = std::move(socket);
|
||||
self.socket->start();
|
||||
|
||||
// 1. Attendre l'ouverture (ou l'échec).
|
||||
bool opened = false;
|
||||
{
|
||||
std::unique_lock lock(self.mutex);
|
||||
const bool signalled =
|
||||
self.cv.wait_until(lock, deadline, [&self] { return self.open || self.closed; });
|
||||
opened = signalled && self.open && !self.closed;
|
||||
if (!opened && signalled) {
|
||||
const ErrorCode kind = self.disconnect_kind;
|
||||
const std::uint16_t code = self.close_code;
|
||||
const std::string reason = self.close_reason;
|
||||
lock.unlock();
|
||||
self.clear_handshake();
|
||||
self.shutdown_socket();
|
||||
if (kind == ErrorCode::ConnectFailed) {
|
||||
return failure<Welcome>(ErrorCode::ConnectFailed,
|
||||
reason.empty() ? std::string("Connexion impossible.") : reason);
|
||||
}
|
||||
return failure<Welcome>(
|
||||
ErrorCode::Closed,
|
||||
reason.empty() ? std::string("Connexion fermée avant la poignée de main.") : reason,
|
||||
std::string{}, code);
|
||||
}
|
||||
}
|
||||
if (!opened) {
|
||||
self.clear_handshake();
|
||||
self.shutdown_socket();
|
||||
return failure<Welcome>(ErrorCode::Timeout, "Délai de connexion dépassé.");
|
||||
}
|
||||
|
||||
// 2. Poignée de main : hello -> welcome (spec §3).
|
||||
const nlohmann::json hello = {
|
||||
{"v", envelope_version},
|
||||
{"type", "hello"},
|
||||
{"payload",
|
||||
{{"protocolVersion", std::string(protocol_version)},
|
||||
{"client", std::string(host_client_name)}}}};
|
||||
self.socket->sendText(hello.dump());
|
||||
|
||||
{
|
||||
std::unique_lock lock(handshake->mutex);
|
||||
if (!handshake->cv.wait_until(lock, deadline, [&handshake] { return handshake->done; })) {
|
||||
lock.unlock();
|
||||
self.clear_handshake();
|
||||
self.shutdown_socket();
|
||||
return failure<Welcome>(ErrorCode::Timeout, "Délai de poignée de main dépassé.");
|
||||
}
|
||||
}
|
||||
self.clear_handshake();
|
||||
|
||||
if (handshake->disconnected) {
|
||||
const ErrorCode kind = handshake->disconnect_kind;
|
||||
const std::uint16_t code = handshake->close_code;
|
||||
const std::string reason = handshake->close_reason;
|
||||
self.shutdown_socket();
|
||||
if (kind == ErrorCode::ConnectFailed) {
|
||||
return failure<Welcome>(ErrorCode::ConnectFailed,
|
||||
reason.empty() ? std::string("Connexion impossible.") : reason);
|
||||
}
|
||||
return failure<Welcome>(
|
||||
ErrorCode::Closed,
|
||||
reason.empty() ? std::string("Connexion fermée pendant la poignée de main.") : reason,
|
||||
std::string{}, code);
|
||||
}
|
||||
if (handshake->malformed) {
|
||||
self.shutdown_socket();
|
||||
return failure<Welcome>(ErrorCode::MalformedResponse, "Réponse de poignée de main illisible.");
|
||||
}
|
||||
if (handshake->is_error) {
|
||||
const std::string code = handshake->error_code;
|
||||
const std::string message = handshake->error_message;
|
||||
self.shutdown_socket();
|
||||
if (code == "incompatible-protocol") {
|
||||
return failure<Welcome>(ErrorCode::IncompatibleVersion,
|
||||
message.empty() ? std::string("Version incompatible.") : message,
|
||||
code);
|
||||
}
|
||||
return failure<Welcome>(ErrorCode::ServerError, message, code);
|
||||
}
|
||||
if (handshake->response_type != "welcome") {
|
||||
self.shutdown_socket();
|
||||
return failure<Welcome>(ErrorCode::MalformedResponse,
|
||||
"Réponse inattendue : « " + handshake->response_type + " ».");
|
||||
}
|
||||
|
||||
Welcome welcome;
|
||||
try {
|
||||
const nlohmann::json& payload = handshake->response_payload;
|
||||
welcome.protocol_version = payload.at("protocolVersion").get<std::string>();
|
||||
welcome.core_version = payload.at("coreVersion").get<std::string>();
|
||||
welcome.instance_id = payload.at("instanceId").get<std::string>();
|
||||
welcome.pid = payload.at("pid").get<std::int64_t>();
|
||||
welcome.linked = payload.at("linked").get<bool>();
|
||||
} catch (const nlohmann::json::exception&) {
|
||||
self.shutdown_socket();
|
||||
return failure<Welcome>(ErrorCode::MalformedResponse, "Message welcome illisible.");
|
||||
}
|
||||
|
||||
if (!is_compatible_protocol_version(welcome.protocol_version)) {
|
||||
const std::string message = "Version du Core incompatible : « " + welcome.protocol_version +
|
||||
" » (attendu « " + std::string(protocol_version) + " »).";
|
||||
self.shutdown_socket();
|
||||
return failure<Welcome>(ErrorCode::IncompatibleVersion, message);
|
||||
}
|
||||
return success(std::move(welcome));
|
||||
}
|
||||
|
||||
RequestResult CoreClient::request(const std::string& type, const nlohmann::json& payload,
|
||||
std::chrono::milliseconds timeout)
|
||||
{
|
||||
Impl& self = *impl_;
|
||||
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||
|
||||
auto inbox = std::make_shared<Impl::Inbox>();
|
||||
std::string id;
|
||||
{
|
||||
std::lock_guard lock(self.mutex);
|
||||
if (!self.open || self.closed || !self.socket) {
|
||||
return failure<nlohmann::json>(ErrorCode::NotConnected, "Aucune connexion ouverte.");
|
||||
}
|
||||
id = std::to_string(self.next_id++);
|
||||
self.pending.emplace(id, inbox);
|
||||
}
|
||||
|
||||
nlohmann::json envelope;
|
||||
envelope["v"] = envelope_version;
|
||||
envelope["type"] = type;
|
||||
envelope["id"] = id;
|
||||
envelope["payload"] = payload;
|
||||
self.socket->sendText(envelope.dump());
|
||||
|
||||
{
|
||||
std::unique_lock lock(inbox->mutex);
|
||||
if (!inbox->cv.wait_until(lock, deadline, [&inbox] { return inbox->done; })) {
|
||||
lock.unlock();
|
||||
std::lock_guard client_lock(self.mutex);
|
||||
self.pending.erase(id);
|
||||
return failure<nlohmann::json>(ErrorCode::Timeout,
|
||||
"Délai de réponse dépassé pour « " + type + " ».");
|
||||
}
|
||||
}
|
||||
{
|
||||
std::lock_guard client_lock(self.mutex);
|
||||
self.pending.erase(id);
|
||||
}
|
||||
|
||||
if (inbox->disconnected) {
|
||||
return failure<nlohmann::json>(
|
||||
ErrorCode::Closed,
|
||||
inbox->close_reason.empty() ? std::string("Connexion fermée.") : inbox->close_reason,
|
||||
std::string{}, inbox->close_code);
|
||||
}
|
||||
if (inbox->malformed) {
|
||||
return failure<nlohmann::json>(ErrorCode::MalformedResponse, "Réponse illisible du Core.");
|
||||
}
|
||||
if (inbox->is_error) {
|
||||
return failure<nlohmann::json>(ErrorCode::ServerError, inbox->error_message,
|
||||
inbox->error_code);
|
||||
}
|
||||
if (inbox->response_type != type) {
|
||||
return failure<nlohmann::json>(ErrorCode::MalformedResponse,
|
||||
"Type de réponse inattendu : « " + inbox->response_type +
|
||||
" ».");
|
||||
}
|
||||
return success(inbox->response_payload);
|
||||
}
|
||||
|
||||
bool CoreClient::connected() const noexcept
|
||||
{
|
||||
std::lock_guard lock(impl_->mutex);
|
||||
return impl_->open && !impl_->closed && impl_->socket != nullptr;
|
||||
}
|
||||
|
||||
void CoreClient::close()
|
||||
{
|
||||
impl_->shutdown_socket();
|
||||
}
|
||||
|
||||
void CoreClient::on_disconnect(DisconnectCallback callback)
|
||||
{
|
||||
std::lock_guard lock(impl_->mutex);
|
||||
impl_->disconnect_callback = std::move(callback);
|
||||
}
|
||||
|
||||
std::uint64_t CoreClient::ignored_message_count() const noexcept
|
||||
{
|
||||
std::lock_guard lock(impl_->mutex);
|
||||
return impl_->ignored;
|
||||
}
|
||||
|
||||
} // namespace sequa::studio::host
|
||||
@@ -0,0 +1,183 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
#include "sequa/studio/host/wire.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace sequa::studio::host {
|
||||
namespace {
|
||||
|
||||
constexpr char hex_digits[] = "0123456789abcdef";
|
||||
constexpr char base64_alphabet[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string to_hex64(std::uint64_t value)
|
||||
{
|
||||
std::string out(hex64_length, '0');
|
||||
for (std::size_t i = hex64_length; i-- > 0;) {
|
||||
out[i] = hex_digits[value & 0xFU];
|
||||
value >>= 4U;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<std::uint64_t> from_hex64(std::string_view text) noexcept
|
||||
{
|
||||
if (text.size() != hex64_length) {
|
||||
return std::nullopt;
|
||||
}
|
||||
std::uint64_t value = 0;
|
||||
for (const char c : text) {
|
||||
std::uint64_t nibble = 0;
|
||||
if (c >= '0' && c <= '9') {
|
||||
nibble = static_cast<std::uint64_t>(c - '0');
|
||||
} else if (c >= 'a' && c <= 'f') {
|
||||
nibble = static_cast<std::uint64_t>(c - 'a' + 10);
|
||||
} else {
|
||||
return std::nullopt; // majuscules et tout autre caractère refusés
|
||||
}
|
||||
value = (value << 4U) | nibble;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string base64_encode(std::span<const std::uint8_t> bytes)
|
||||
{
|
||||
std::string out;
|
||||
out.reserve(((bytes.size() + 2U) / 3U) * 4U);
|
||||
|
||||
std::size_t i = 0;
|
||||
while (i + 3U <= bytes.size()) {
|
||||
const std::uint32_t triple = (static_cast<std::uint32_t>(bytes[i]) << 16U) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 1U]) << 8U) |
|
||||
static_cast<std::uint32_t>(bytes[i + 2U]);
|
||||
out.push_back(base64_alphabet[(triple >> 18U) & 0x3FU]);
|
||||
out.push_back(base64_alphabet[(triple >> 12U) & 0x3FU]);
|
||||
out.push_back(base64_alphabet[(triple >> 6U) & 0x3FU]);
|
||||
out.push_back(base64_alphabet[triple & 0x3FU]);
|
||||
i += 3U;
|
||||
}
|
||||
|
||||
const std::size_t rest = bytes.size() - i;
|
||||
if (rest == 1U) {
|
||||
const std::uint32_t triple = static_cast<std::uint32_t>(bytes[i]) << 16U;
|
||||
out.push_back(base64_alphabet[(triple >> 18U) & 0x3FU]);
|
||||
out.push_back(base64_alphabet[(triple >> 12U) & 0x3FU]);
|
||||
out.push_back('=');
|
||||
out.push_back('=');
|
||||
} else if (rest == 2U) {
|
||||
const std::uint32_t triple = (static_cast<std::uint32_t>(bytes[i]) << 16U) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 1U]) << 8U);
|
||||
out.push_back(base64_alphabet[(triple >> 18U) & 0x3FU]);
|
||||
out.push_back(base64_alphabet[(triple >> 12U) & 0x3FU]);
|
||||
out.push_back(base64_alphabet[(triple >> 6U) & 0x3FU]);
|
||||
out.push_back('=');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string base64_encode(std::string_view text)
|
||||
{
|
||||
return base64_encode(std::span<const std::uint8_t>(
|
||||
reinterpret_cast<const std::uint8_t*>(text.data()), text.size()));
|
||||
}
|
||||
|
||||
std::optional<std::vector<std::uint8_t>> base64_decode(std::string_view text)
|
||||
{
|
||||
if (text.size() % 4U != 0U) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> out;
|
||||
out.reserve(text.size() / 4U * 3U);
|
||||
if (text.empty()) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Remplissage uniquement en fin de chaîne (0, 1 ou 2 '=').
|
||||
std::size_t padding = 0;
|
||||
if (text[text.size() - 1U] == '=') {
|
||||
padding = 1U;
|
||||
}
|
||||
if (text.size() >= 2U && text[text.size() - 2U] == '=') {
|
||||
padding = 2U;
|
||||
}
|
||||
const std::size_t data_length = text.size() - padding;
|
||||
|
||||
std::uint32_t accumulator = 0;
|
||||
int bits = 0;
|
||||
for (std::size_t i = 0; i < data_length; ++i) {
|
||||
const char c = text[i];
|
||||
int value = -1;
|
||||
if (c >= 'A' && c <= 'Z') {
|
||||
value = c - 'A';
|
||||
} else if (c >= 'a' && c <= 'z') {
|
||||
value = c - 'a' + 26;
|
||||
} else if (c >= '0' && c <= '9') {
|
||||
value = c - '0' + 52;
|
||||
} else if (c == '+') {
|
||||
value = 62;
|
||||
} else if (c == '/') {
|
||||
value = 63;
|
||||
} else {
|
||||
return std::nullopt; // '=' au milieu ou caractère hors alphabet
|
||||
}
|
||||
accumulator = (accumulator << 6U) | static_cast<std::uint32_t>(value);
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.push_back(static_cast<std::uint8_t>((accumulator >> static_cast<unsigned>(bits)) & 0xFFU));
|
||||
// Ne conserver que les bits non encore consommés.
|
||||
accumulator = bits == 0 ? 0U : accumulator & ((1U << static_cast<unsigned>(bits)) - 1U);
|
||||
}
|
||||
}
|
||||
|
||||
// Bits de queue : doivent être nuls (RFC 4648 strict).
|
||||
if (bits > 0 && (accumulator & ((1U << static_cast<unsigned>(bits)) - 1U)) != 0U) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Cohérence du remplissage avec la longueur des données.
|
||||
const std::size_t remainder = data_length % 4U;
|
||||
if ((padding == 0U && remainder != 0U) || (padding == 1U && remainder != 3U) ||
|
||||
(padding == 2U && remainder != 2U)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<std::vector<std::uint64_t>> decode_hashes(std::string_view base64)
|
||||
{
|
||||
std::optional<std::vector<std::uint8_t>> bytes = base64_decode(base64);
|
||||
if (!bytes.has_value() || bytes->size() % 8U != 0U) {
|
||||
return std::nullopt;
|
||||
}
|
||||
std::vector<std::uint64_t> hashes;
|
||||
hashes.reserve(bytes->size() / 8U);
|
||||
for (std::size_t i = 0; i < bytes->size(); i += 8U) {
|
||||
std::uint64_t value = 0;
|
||||
for (std::size_t k = 0; k < 8U; ++k) {
|
||||
value |= static_cast<std::uint64_t>((*bytes)[i + k]) << (8U * k);
|
||||
}
|
||||
hashes.push_back(value);
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
std::optional<std::size_t> first_divergence(std::span<const std::uint64_t> expected,
|
||||
std::span<const std::uint64_t> actual) noexcept
|
||||
{
|
||||
const std::size_t common = std::min(expected.size(), actual.size());
|
||||
for (std::size_t i = 0; i < common; ++i) {
|
||||
if (expected[i] != actual[i]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
if (expected.size() != actual.size()) {
|
||||
return common;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
} // 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,430 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
//
|
||||
// Tests d'intégration du CoreClient contre un FAUX serveur WebSocket minimal qui suit la
|
||||
// spécification 0.2 (welcome, réponses avec id recopié, error, requête lente, fermeture
|
||||
// volontaire). Ce test ne dépend PAS de sequa_core_server : le Core 0.2 est réécrit en
|
||||
// parallèle par un autre agent.
|
||||
#include "sequa/studio/host/client.hpp"
|
||||
#include "sequa/studio/host/wire.hpp"
|
||||
|
||||
#include <doctest/doctest.h>
|
||||
#include <ixwebsocket/IXNetSystem.h>
|
||||
#include <ixwebsocket/IXWebSocketServer.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using namespace sequa::studio::host;
|
||||
using nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
// IXWebSocket ne rapporte pas le port choisi par le système pour le port 0 : on parcourt une
|
||||
// plage de ports locaux jusqu'au premier libre (plage réservée à cette brique).
|
||||
constexpr int first_port = 19300;
|
||||
constexpr int last_port = 19399;
|
||||
|
||||
std::string encode_message(const std::string& type, const std::string& id, const json& payload)
|
||||
{
|
||||
json envelope{{"v", 1}, {"type", type}, {"payload", payload}};
|
||||
if (!id.empty()) {
|
||||
envelope["id"] = id;
|
||||
}
|
||||
return envelope.dump();
|
||||
}
|
||||
|
||||
std::string encode_error(const std::string& id, const std::string& code,
|
||||
const std::string& message)
|
||||
{
|
||||
return encode_message("error", id, json{{"code", code}, {"message", message}});
|
||||
}
|
||||
|
||||
// Faux Core : suit le profil minimal du protocole 0.2.
|
||||
class FakeCore {
|
||||
public:
|
||||
enum class Handshake { welcome, welcome_incompatible, error_incompatible };
|
||||
|
||||
explicit FakeCore(Handshake handshake = Handshake::welcome) : handshake_(handshake)
|
||||
{
|
||||
ix::initNetSystem();
|
||||
}
|
||||
|
||||
~FakeCore()
|
||||
{
|
||||
stop();
|
||||
ix::uninitNetSystem();
|
||||
}
|
||||
|
||||
FakeCore(const FakeCore&) = delete;
|
||||
FakeCore& operator=(const FakeCore&) = delete;
|
||||
|
||||
bool start()
|
||||
{
|
||||
for (int port = first_port; port <= last_port; ++port) {
|
||||
auto server = std::make_unique<ix::WebSocketServer>(port, "127.0.0.1");
|
||||
server->disablePerMessageDeflate();
|
||||
server->setOnClientMessageCallback(
|
||||
[this](std::shared_ptr<ix::ConnectionState>, ix::WebSocket& socket,
|
||||
const ix::WebSocketMessagePtr& message) { on_message(socket, message); });
|
||||
if (server->listenAndStart()) {
|
||||
server_ = std::move(server);
|
||||
port_ = port;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void stop()
|
||||
{
|
||||
if (server_) {
|
||||
server_->stop();
|
||||
server_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] int port() const { return port_; }
|
||||
|
||||
[[nodiscard]] std::string url() const
|
||||
{
|
||||
return "ws://127.0.0.1:" + std::to_string(port_) + "/";
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string url_with_token(const std::string& token) const
|
||||
{
|
||||
return "ws://127.0.0.1:" + std::to_string(port_) + "/?token=" + token;
|
||||
}
|
||||
|
||||
// URI vue par le serveur (chemin + requête), pour vérifier que le jeton est bien transmis.
|
||||
[[nodiscard]] std::string last_uri() const
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
return last_uri_;
|
||||
}
|
||||
|
||||
[[nodiscard]] json received_hello() const
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
return hello_;
|
||||
}
|
||||
|
||||
private:
|
||||
void on_message(ix::WebSocket& socket, const ix::WebSocketMessagePtr& message)
|
||||
{
|
||||
switch (message->type) {
|
||||
case ix::WebSocketMessageType::Open:
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
last_uri_ = message->openInfo.uri;
|
||||
}
|
||||
return;
|
||||
case ix::WebSocketMessageType::Message:
|
||||
handle_text(socket, message->str);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void handle_text(ix::WebSocket& socket, const std::string& text)
|
||||
{
|
||||
json envelope;
|
||||
try {
|
||||
envelope = json::parse(text);
|
||||
} catch (const json::exception&) {
|
||||
return;
|
||||
}
|
||||
if (!envelope.is_object()) {
|
||||
return;
|
||||
}
|
||||
const std::string type = envelope.value("type", std::string());
|
||||
const std::string id =
|
||||
envelope.contains("id") && envelope["id"].is_string() ? envelope["id"].get<std::string>()
|
||||
: std::string();
|
||||
|
||||
if (type == "hello") {
|
||||
const json payload = envelope.value("payload", json::object());
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
hello_ = payload;
|
||||
}
|
||||
if (handshake_ == Handshake::error_incompatible) {
|
||||
socket.sendText(encode_error(id, "incompatible-protocol", "Version incompatible (test)."));
|
||||
socket.close(1008, "incompatible-protocol");
|
||||
return;
|
||||
}
|
||||
socket.sendText(encode_message("welcome", id, welcome_payload()));
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Après la poignée de main ---------------------------------------------------
|
||||
if (type == "core/ping") {
|
||||
socket.sendText(encode_message(type, id, json::object()));
|
||||
return;
|
||||
}
|
||||
if (type == "session/status") {
|
||||
socket.sendText(encode_message(type, id, status_payload()));
|
||||
return;
|
||||
}
|
||||
if (type == "test/echo") {
|
||||
socket.sendText(encode_message(type, id, envelope.value("payload", json::object())));
|
||||
return;
|
||||
}
|
||||
if (type == "test/error") {
|
||||
socket.sendText(encode_error(id, "boom", "erreur simulée"));
|
||||
return;
|
||||
}
|
||||
if (type == "test/slow") {
|
||||
return; // aucune réponse : provoque le délai dépassé côté client
|
||||
}
|
||||
if (type == "test/close") {
|
||||
socket.close(1001, "fermé par le serveur");
|
||||
return;
|
||||
}
|
||||
socket.sendText(encode_error(id, "unknown-type", "Type inconnu."));
|
||||
}
|
||||
|
||||
[[nodiscard]] json welcome_payload() const
|
||||
{
|
||||
const std::string version =
|
||||
handshake_ == Handshake::welcome ? "0.2.0" : "0.1.0";
|
||||
return json{{"protocolVersion", version},
|
||||
{"coreVersion", "9.9.9-test"},
|
||||
{"instanceId", std::string("0123456789abcdef0123456789abcdef")},
|
||||
{"pid", 4242},
|
||||
{"linked", true}};
|
||||
}
|
||||
|
||||
[[nodiscard]] json status_payload() const
|
||||
{
|
||||
return json{{"seed", "0000000000000000"},
|
||||
{"tickRateHz", 60},
|
||||
{"tick", 7},
|
||||
{"stateHash", "1111111111111111"},
|
||||
{"running", true},
|
||||
{"inputCount", 0}};
|
||||
}
|
||||
|
||||
Handshake handshake_;
|
||||
std::unique_ptr<ix::WebSocketServer> server_;
|
||||
int port_ = 0;
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
std::string last_uri_;
|
||||
json hello_;
|
||||
};
|
||||
|
||||
std::unique_ptr<FakeCore> make_core(FakeCore::Handshake handshake = FakeCore::Handshake::welcome)
|
||||
{
|
||||
auto core = std::make_unique<FakeCore>(handshake);
|
||||
if (!core->start()) {
|
||||
return nullptr;
|
||||
}
|
||||
return core;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("hostclient: connexion nominale, champs de welcome et URL transmise telle quelle")
|
||||
{
|
||||
const auto core = make_core();
|
||||
REQUIRE(core);
|
||||
CoreClient client;
|
||||
|
||||
const ConnectResult result =
|
||||
client.connect(core->url_with_token("abc123"), std::chrono::seconds(5));
|
||||
REQUIRE(result.ok());
|
||||
CHECK(client.connected());
|
||||
|
||||
CHECK(result.value->protocol_version == "0.2.0");
|
||||
CHECK(result.value->core_version == "9.9.9-test");
|
||||
CHECK(result.value->instance_id == "0123456789abcdef0123456789abcdef");
|
||||
CHECK(result.value->pid == 4242);
|
||||
CHECK(result.value->linked);
|
||||
|
||||
// Le host s'annonce comme `sequa-studio-host` en 0.2.0.
|
||||
CHECK(core->received_hello().value("protocolVersion", std::string()) == "0.2.0");
|
||||
CHECK(core->received_hello().value("client", std::string()) == "sequa-studio-host");
|
||||
|
||||
// Le jeton de l'URL est transmis tel quel au serveur.
|
||||
CHECK(core->last_uri() == "/?token=abc123");
|
||||
|
||||
client.close();
|
||||
CHECK_FALSE(client.connected());
|
||||
}
|
||||
|
||||
TEST_CASE("hostclient: requete/reponse avec id (core/ping et session/status)")
|
||||
{
|
||||
const auto core = make_core();
|
||||
REQUIRE(core);
|
||||
CoreClient client;
|
||||
REQUIRE(client.connect(core->url(), std::chrono::seconds(5)).ok());
|
||||
|
||||
const RequestResult ping = client.request("core/ping", json::object(), std::chrono::seconds(5));
|
||||
REQUIRE(ping.ok());
|
||||
CHECK(ping.value->is_object());
|
||||
CHECK(ping.value->empty());
|
||||
|
||||
const RequestResult status =
|
||||
client.request("session/status", json::object(), std::chrono::seconds(5));
|
||||
REQUIRE(status.ok());
|
||||
CHECK((*status.value)["tick"] == 7);
|
||||
CHECK((*status.value)["tickRateHz"] == 60);
|
||||
CHECK((*status.value)["running"] == true);
|
||||
}
|
||||
|
||||
TEST_CASE("hostclient: 8 requetes concurrentes depuis 8 fils, chacune recoit SA reponse")
|
||||
{
|
||||
const auto core = make_core();
|
||||
REQUIRE(core);
|
||||
CoreClient client;
|
||||
REQUIRE(client.connect(core->url(), std::chrono::seconds(5)).ok());
|
||||
|
||||
constexpr int request_count = 8;
|
||||
std::vector<std::future<RequestResult>> futures;
|
||||
futures.reserve(request_count);
|
||||
for (int i = 0; i < request_count; ++i) {
|
||||
futures.push_back(std::async(std::launch::async, [&client, i] {
|
||||
return client.request("test/echo", json{{"n", i}}, std::chrono::seconds(5));
|
||||
}));
|
||||
}
|
||||
|
||||
for (int i = 0; i < request_count; ++i) {
|
||||
const RequestResult result = futures[static_cast<std::size_t>(i)].get();
|
||||
REQUIRE(result.ok());
|
||||
CHECK((*result.value)["n"] == i);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("hostclient: timeout d'une requete lente")
|
||||
{
|
||||
const auto core = make_core();
|
||||
REQUIRE(core);
|
||||
CoreClient client;
|
||||
REQUIRE(client.connect(core->url(), std::chrono::seconds(5)).ok());
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
const RequestResult result =
|
||||
client.request("test/slow", json::object(), std::chrono::milliseconds(200));
|
||||
const auto elapsed = std::chrono::steady_clock::now() - start;
|
||||
|
||||
REQUIRE_FALSE(result.ok());
|
||||
CHECK(result.error->code == ErrorCode::Timeout);
|
||||
CHECK(elapsed < std::chrono::seconds(3));
|
||||
// La connexion reste utilisable après un délai dépassé.
|
||||
CHECK(client.request("core/ping", json::object(), std::chrono::seconds(5)).ok());
|
||||
}
|
||||
|
||||
TEST_CASE("hostclient: deconnexion pendant l'attente -> erreur rapide")
|
||||
{
|
||||
const auto core = make_core();
|
||||
REQUIRE(core);
|
||||
CoreClient client;
|
||||
REQUIRE(client.connect(core->url(), std::chrono::seconds(5)).ok());
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
const RequestResult result =
|
||||
client.request("test/close", json::object(), std::chrono::seconds(10));
|
||||
const auto elapsed = std::chrono::steady_clock::now() - start;
|
||||
|
||||
REQUIRE_FALSE(result.ok());
|
||||
CHECK(result.error->code == ErrorCode::Closed);
|
||||
CHECK(result.error->close_code == 1001U);
|
||||
CHECK(elapsed < std::chrono::seconds(1));
|
||||
CHECK_FALSE(client.connected());
|
||||
}
|
||||
|
||||
TEST_CASE("hostclient: error du serveur et type inconnu")
|
||||
{
|
||||
const auto core = make_core();
|
||||
REQUIRE(core);
|
||||
CoreClient client;
|
||||
REQUIRE(client.connect(core->url(), std::chrono::seconds(5)).ok());
|
||||
|
||||
const RequestResult error = client.request("test/error", json::object(), std::chrono::seconds(5));
|
||||
REQUIRE_FALSE(error.ok());
|
||||
CHECK(error.error->code == ErrorCode::ServerError);
|
||||
CHECK(error.error->server_code == "boom");
|
||||
CHECK(error.error->message == "erreur simulée");
|
||||
|
||||
const RequestResult unknown = client.request("nope/thing", json::object(), std::chrono::seconds(5));
|
||||
REQUIRE_FALSE(unknown.ok());
|
||||
CHECK(unknown.error->code == ErrorCode::ServerError);
|
||||
CHECK(unknown.error->server_code == "unknown-type");
|
||||
}
|
||||
|
||||
TEST_CASE("hostclient: version incompatible annoncee dans welcome")
|
||||
{
|
||||
const auto core = make_core(FakeCore::Handshake::welcome_incompatible);
|
||||
REQUIRE(core);
|
||||
CoreClient client;
|
||||
|
||||
const ConnectResult result = client.connect(core->url(), std::chrono::seconds(5));
|
||||
REQUIRE_FALSE(result.ok());
|
||||
CHECK(result.error->code == ErrorCode::IncompatibleVersion);
|
||||
CHECK_FALSE(client.connected());
|
||||
}
|
||||
|
||||
TEST_CASE("hostclient: error incompatible-protocol du serveur")
|
||||
{
|
||||
const auto core = make_core(FakeCore::Handshake::error_incompatible);
|
||||
REQUIRE(core);
|
||||
CoreClient client;
|
||||
|
||||
const ConnectResult result = client.connect(core->url(), std::chrono::seconds(5));
|
||||
REQUIRE_FALSE(result.ok());
|
||||
CHECK(result.error->code == ErrorCode::IncompatibleVersion);
|
||||
CHECK(result.error->server_code == "incompatible-protocol");
|
||||
}
|
||||
|
||||
TEST_CASE("hostclient: connexion impossible")
|
||||
{
|
||||
CoreClient client;
|
||||
// IXWebSocket rapporte le refus de connexion en environ 2 s : délai généreux, mais le
|
||||
// résultat doit rester ConnectFailed (et non Timeout).
|
||||
const ConnectResult result =
|
||||
client.connect("ws://127.0.0.1:19399/?token=x", std::chrono::milliseconds(5000));
|
||||
REQUIRE_FALSE(result.ok());
|
||||
CHECK(result.error->code == ErrorCode::ConnectFailed);
|
||||
CHECK_FALSE(client.connected());
|
||||
}
|
||||
|
||||
TEST_CASE("hostclient: on_disconnect appele une seule fois")
|
||||
{
|
||||
const auto core = make_core();
|
||||
REQUIRE(core);
|
||||
CoreClient client;
|
||||
REQUIRE(client.connect(core->url(), std::chrono::seconds(5)).ok());
|
||||
|
||||
std::mutex mutex;
|
||||
std::condition_variable changed;
|
||||
int calls = 0;
|
||||
client.on_disconnect([&](std::uint16_t, const std::string&) {
|
||||
std::lock_guard lock(mutex);
|
||||
++calls;
|
||||
changed.notify_all();
|
||||
});
|
||||
|
||||
client.close();
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
changed.wait_for(lock, std::chrono::seconds(2), [&] { return calls > 0; });
|
||||
}
|
||||
CHECK(calls == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("hostclient: requete sans connexion -> not-connected")
|
||||
{
|
||||
CoreClient client;
|
||||
const RequestResult result = client.request("core/ping", json::object(), std::chrono::seconds(1));
|
||||
REQUIRE_FALSE(result.ok());
|
||||
CHECK(result.error->code == ErrorCode::NotConnected);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
//
|
||||
// Tests des aides de représentation du protocole (spec §2, §7.6) : hex64, base64 RFC 4648,
|
||||
// décodage d'empreintes et first_divergence.
|
||||
#include "sequa/studio/host/wire.hpp"
|
||||
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
using namespace sequa::studio::host;
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<std::uint8_t> bytes_of(std::string_view text)
|
||||
{
|
||||
std::vector<std::uint8_t> out;
|
||||
out.reserve(text.size());
|
||||
for (const char c : text) {
|
||||
out.push_back(static_cast<std::uint8_t>(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("hostclient: hex64 aller-retour et refus stricts")
|
||||
{
|
||||
CHECK(to_hex64(0) == "0000000000000000");
|
||||
CHECK(to_hex64(0x0123456789abcdefULL) == "0123456789abcdef");
|
||||
CHECK(to_hex64(std::numeric_limits<std::uint64_t>::max()) == "ffffffffffffffff");
|
||||
|
||||
CHECK(from_hex64("0000000000000000") == 0U);
|
||||
CHECK(from_hex64("0123456789abcdef") == 0x0123456789abcdefULL);
|
||||
CHECK(from_hex64("ffffffffffffffff") == std::numeric_limits<std::uint64_t>::max());
|
||||
|
||||
// Refus strict : majuscules, longueur incorrecte, caractère hors alphabet.
|
||||
CHECK_FALSE(from_hex64("0123456789ABCDEF").has_value());
|
||||
CHECK_FALSE(from_hex64("0123456789abcde").has_value());
|
||||
CHECK_FALSE(from_hex64("0123456789abcdef0").has_value());
|
||||
CHECK_FALSE(from_hex64("0123456789abcdeg").has_value());
|
||||
CHECK_FALSE(from_hex64("").has_value());
|
||||
|
||||
const std::uint64_t values[] = {1U, 255U, 65536U, 0xdeadbeefcafef00dULL};
|
||||
for (const std::uint64_t value : values) {
|
||||
CHECK(from_hex64(to_hex64(value)) == value);
|
||||
CHECK(to_hex64(value).size() == hex64_length);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("hostclient: base64 RFC 4648 (vecteurs et refus)")
|
||||
{
|
||||
// Vecteurs de test de la RFC 4648 §10.
|
||||
CHECK(base64_encode("") == "");
|
||||
CHECK(base64_encode("f") == "Zg==");
|
||||
CHECK(base64_encode("fo") == "Zm8=");
|
||||
CHECK(base64_encode("foo") == "Zm9v");
|
||||
CHECK(base64_encode("foob") == "Zm9vYg==");
|
||||
CHECK(base64_encode("fooba") == "Zm9vYmE=");
|
||||
CHECK(base64_encode("foobar") == "Zm9vYmFy");
|
||||
|
||||
const auto decoded = base64_decode("Zm9vYmFy");
|
||||
REQUIRE(decoded.has_value());
|
||||
CHECK(*decoded == bytes_of("foobar"));
|
||||
|
||||
CHECK(base64_decode("") == std::optional<std::vector<std::uint8_t>>(std::vector<std::uint8_t>{}));
|
||||
const auto one = base64_decode("Zg==");
|
||||
REQUIRE(one.has_value());
|
||||
CHECK(*one == bytes_of("f"));
|
||||
|
||||
// Refus stricts.
|
||||
CHECK_FALSE(base64_decode("Zm9vYmF").has_value()); // longueur non multiple de 4
|
||||
CHECK_FALSE(base64_decode("Zm9v!mFy").has_value()); // caractère hors alphabet
|
||||
CHECK_FALSE(base64_decode("Zg=").has_value()); // remplissage incomplet
|
||||
CHECK_FALSE(base64_decode("=Zg=").has_value()); // '=' en tête
|
||||
CHECK_FALSE(base64_decode("Zg==Zg==").has_value()); // '=' au milieu
|
||||
CHECK_FALSE(base64_decode("Zh==").has_value()); // bits de queue non nuls
|
||||
|
||||
// Aller-retour sur des octets binaires quelconques.
|
||||
const std::vector<std::uint8_t> raw{0U, 1U, 2U, 250U, 255U};
|
||||
const auto round_trip = base64_decode(base64_encode(std::span<const std::uint8_t>(raw)));
|
||||
REQUIRE(round_trip.has_value());
|
||||
CHECK(*round_trip == raw);
|
||||
}
|
||||
|
||||
TEST_CASE("hostclient: decode_hashes et first_divergence")
|
||||
{
|
||||
const std::vector<std::uint64_t> a{1U, 2U, 3U, 4U};
|
||||
CHECK(first_divergence(a, a) == std::nullopt);
|
||||
|
||||
const std::vector<std::uint64_t> b{1U, 2U, 99U, 4U};
|
||||
CHECK(first_divergence(a, b) == std::size_t{2});
|
||||
CHECK(first_divergence(b, a) == std::size_t{2});
|
||||
|
||||
const std::vector<std::uint64_t> prefix{1U, 2U};
|
||||
CHECK(first_divergence(a, prefix) == std::size_t{2});
|
||||
CHECK(first_divergence(prefix, a) == std::size_t{2});
|
||||
|
||||
// decode_hashes : tableau {1, 2} en uint64 petit-boutiste.
|
||||
const std::vector<std::uint8_t> little_endian{1U, 0U, 0U, 0U, 0U, 0U, 0U, 0U,
|
||||
2U, 0U, 0U, 0U, 0U, 0U, 0U, 0U};
|
||||
const auto hashes = decode_hashes(base64_encode(std::span<const std::uint8_t>(little_endian)));
|
||||
REQUIRE(hashes.has_value());
|
||||
CHECK(*hashes == (std::vector<std::uint64_t>{1U, 2U}));
|
||||
}
|
||||
|
||||
TEST_CASE("hostclient: decode_hashes refuse les longueurs invalides")
|
||||
{
|
||||
// 5 octets : pas un multiple de 8.
|
||||
CHECK_FALSE(decode_hashes(base64_encode("trois")).has_value());
|
||||
// Base64 invalide.
|
||||
CHECK_FALSE(decode_hashes("!!").has_value());
|
||||
}
|
||||
Reference in new issue
Block a user