Cette page n’est pas encore traduite : la version anglaise est affichée.
Threads, cycle de vie et tests
La boucle vous appartient, et le SDK livre à un seul endroit. Le SDK ne lance aucun thread dont vous devriez connaître l’existence, ne vous confie aucun verrou et appelle votre code depuis un contexte unique, choisi par vous au démarrage. Appelez-nous depuis le thread de votre choix ; nous vous appelons depuis un seul.
Un seul contexte de livraison, et la boucle vous appartient
Une instance déclare un seul et unique contexte de livraison — l’endroit où s’exécutent tous ses handlers. Il est fixé à l’initialisation et ne change plus pendant toute la vie de l’instance. Un événement, un delta de données, le résultat d’un appel : tout arrive là, et nulle part ailleurs.
Ce contexte existe sous deux formes, et vous en choisissez une au démarrage :
- Vous le pompez. Le runtime ne fait rien de lui-même ; c’est votre propre boucle qui vide les livraisons en attente. C’est la forme qui convient à un moteur : les livraisons arrivent sur le thread de jeu, dans la frame que vous avez choisie.
- Nous le gérons. Le runtime détient un thread d’exécution dédié. C’est la forme qui convient à un hôte en mode console ou à un serveur dédié.
Aucune des deux n’est une solution de repli pour l’autre, et il n’existe pas de troisième option à base de pool de threads : combien de threads le runtime détient n’est jamais une question que votre code a à se poser.
Le contexte n’est jamais un argument. Aucun handler ne reçoit de paramètre « sur quel thread suis-je », et il n’y a rien à interroger. L’endroit où s’exécute votre handler est une propriété du contrat, pas une donnée de l’appel.
Le démarrage et l’arrêt sont explicites
L’initialisation est un appel que vous faites, et elle répond par un résultat. Rien ne s’initialise paresseusement à la première utilisation. Un démarrage paresseux déplacerait le seul endroit où un module désactivé est visible vers l’appel qui se trouverait être le premier, où cela passerait pour un échec de cet appel.
Un module désactivé est signalé au démarrage, et le résultat dit lequel de deux cas s’est produit : soit toute la chaîne dépendante est désactivée, soit vous fonctionnez avec moins, accompagné de la liste de ce qui est indisponible. Il n’y a pas de troisième cas silencieux.
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump); // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);
void OnFrame() => pump.Drain(); // your loop, your frame
await runtime.DisposeAsync(); // explicit, idempotent// the outcome names a disabled module and what it took with it — it is not a thrown error
const outcome = await PlayServ.runtime.initialize({
delivery: PlayServ.delivery.pumped(), // or .owned()
});
outcome.unavailable.forEach(log);
const onFrame = () => outcome.pump.drain(); // your loop, your frame
await runtime.close(); // explicit, idempotent# the outcome names a disabled module and what it took with it — it is not an exception
outcome = await playserv.runtime.initialize(
delivery=playserv.delivery.pumped(), # or .owned()
)
for gap in outcome.unavailable:
log(gap)
def on_frame():
outcome.pump.drain() # your loop, your frame
await runtime.close() # explicit, idempotentLa déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.
// deliveries land on the game thread; a gap is a named outcome, not an exception
FPlayServClient::Connect(Options,
TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
{
if (!Result.HasValue()) { return; }
FPlayServClient* Client = Result.Value();
for (const FPSGap& Gap : Client->Unavailable())
{
UE_LOG(LogPlayServ, Warning, TEXT("%s"), *Gap.Text);
}
}));
// no pump call: the plugin drains on the game thread for you
Client->Shutdown(); // explicit, idempotent — and not a cancel
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump); // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);
void OnFrame() => pump.Drain(); // your loop, your frame
await runtime.DisposeAsync(); // explicit, idempotentL’arrêt est explicite, complet et idempotent : une fois qu’il a réussi, plus aucun handler de cette instance n’est appelé.
L’arrêt n’annule pas le travail en cours
L’arrêt ne dit rien des opérations déjà lancées. Chacune reste traçable par le moyen que cette opération a indiqué ; si vous devez savoir si un achat a abouti, posez la question par ce moyen, pas en arrêtant l’instance.
Chaque handle a une seule fin déclarée
Un abonnement, un descripteur de tâche différée, une session : chacun est un handle, et chacun a exactement une fin, nommée par le contrat. La libération est idempotente : libérer deux fois n’est pas une erreur.
Trois conséquences sur lesquelles on se trompe facilement :
- La fin n’est jamais un finaliseur, un destructeur ni une portée. Un handle que vous laissez tomber reste ouvert. C’est un bug de votre code, pas quelque chose que nous récupérons discrètement — car une durée de vie qui dépendrait du langage serait différente dans chaque binding.
- Utiliser un handle après sa fin donne un refus déclaré, avec un code. Pas un résultat vide, pas un comportement indéfini, ni une erreur générique d’objet libéré qui ne vous donne rien sur quoi agir.
- Une connexion perdue n’est pas la fin d’un handle. Un abonnement survit à une déconnexion et continue de recevoir après la reconnexion. Les handles prennent fin pour les raisons que nomme le contrat, et la perte du réseau n’en fait pas partie.
Aucun handle ne survit à l’instance qui l’a émis : une fois l’arrêt effectué, chaque handle qu’elle vous a donné est arrivé à sa fin.
Appeler depuis un handler, oui ; attendre à l’intérieur, non
Appelez l’interface depuis n’importe lequel de vos threads. Chaque handle est utilisable depuis tous les threads, et c’est une promesse, pas une propriété du build actuel. Aucune primitive de synchronisation ne fait partie de l’interface.
Les handlers d’une même instance sont sérialisés : deux ne s’exécutent jamais en même temps, et l’ordre au sein d’un flux est préservé. Un handler n’a donc besoin d’aucun verrou à lui.
Sérialisé ne veut pas dire dédupliqué. L’ordre est une promesse ; le nombre de fois qu’un message est livré en est une autre, déclarée sur le type de message. En at-least-once, vous verrez le même message deux fois, et c’est la clé de déduplication, qui l’accompagne toujours, qui vous permet de le reconnaître.
Lancer une opération depuis un handler est permis et ne peut pas provoquer d’interblocage. Son résultat, en revanche, n’arrive jamais dans ce même handler : il revient sous forme de livraison distincte sur le même contexte.
Ne bloquez jamais le contexte de livraison
Dans un handler, n’attendez ni le réseau, ni le verrou de quelqu’un d’autre, ni, de façon synchrone, votre propre appel.
Cette interdiction a un symptôme : un handler qui garde le contexte au-delà de son budget déclaré produit soit une dégradation déclarée de la livraison, soit un refus déclaré. Il ne produit jamais un ralentissement silencieux que vous découvririez dans la session d’un joueur.
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
await player.Inventory.Grant(e.Loot); // started, not awaited-to-completion inside the context
}); // ...the grant's outcome arrives on its own
await sub.DisposeAsync(); // stop receiving — local, works with the network down// legal: start and return. The outcome is a later delivery, not a value here.
const sub = await room.events.subscribe(CrateOpened, async (e) => {
await player.inventory.grant(e.loot);
});
await sub.close(); // stop receiving — local, works with the network down# legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.events.subscribe(CrateOpened, lambda e: player.inventory.grant(e.loot))
await sub.close() # stop receiving — local, works with the network downLa déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.
// subscribing is local and immediate; the outcome is a later delivery, on the game thread
TPSSubscription LootWatch = Room->Subscribe->CrateOpened(
[this](const FCrateOpened& Opened) { GrantLoot(Opened.Loot); });
LootWatch.Unsubscribe(); // stop receiving — local, works with the network down
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
await player.Inventory.Grant(e.Loot); // started, not awaited-to-completion inside the context
}); // ...the grant's outcome arrives on its own
await sub.DisposeAsync(); // stop receiving — local, works with the network down« Annuler » recouvre deux choses différentes
Un seul mot dans la plupart des langages, deux opérations ici, et la différence est observable :
| Ce que vous voulez | Ce que c’est |
|---|---|
| ne plus me livrer | local. Réussit toujours, y compris sans connexion. Libérer un abonnement, c’est cela. |
| arrêter le travail | une requête adressée à la plateforme. Idempotente, et elle ne promet rien sur le fait que le travail ait eu lieu ou non. |
C’est sur la seconde qu’on se trompe. Annuler un travail accepté est une requête qui peut ne pas arriver à temps — exactement comme un timeout, qui ne veut pas dire non plus « cela n’a pas été appliqué ». Après l’une ou l’autre forme d’annulation, le résultat d’une opération non idempotente déjà lancée reste traçable par le moyen que cette opération a indiqué.
Et les deux échecs se distinguent : annuler un appel qui ne nous est jamais parvenu est un échec local ; annuler un travail que nous avions accepté vous donne un état terminal parmi un ensemble déclaré.
Ce que vous recevez est une copie du passé
Une valeur livrée à votre handler ne change plus ensuite. Nous ne vous donnons jamais de référence vivante vers notre propre état : rien de ce que vous tenez ne change entre deux lignes de votre code.
Conserver une valeur livrée au-delà du handler est donc sans danger — mais ce que vous gardez est l’observation d’un instant, pas une fenêtre sur le présent. Des deltas peuvent être fusionnés en chemin, si bien qu’une liste de valeurs que vous avez enregistrées n’est pas l’historique de ce qui s’est passé.
Vous ne possédez jamais non plus nos buffers. Pas d’emprunt-restitution, pas d’assemblage avant envoi : modifier l’état déclaré, c’est déjà l’opération réseau.
Tests : une implémentation en mémoire, pas un mock
Il existe une implémentation complète en mémoire — même interface, même ensemble de résultats déclarés, sans réseau. C’est une dépendance distincte, pas une option du runtime de production.
- Elle n’est pas partielle. Une opération qu’elle ne prend pas en charge est refusée avec un code déclaré, jamais accueillie par un succès inventé.
- Le temps vous appartient. Les échéances déclarées — la durée de vie d’un descripteur de tâche, une réservation, une fenêtre de rétention — s’atteignent en avançant d’un pas, pas en attendant.
- Le déterminisme est déclaré et borné : l’ordre au sein d’un flux, le mode de livraison déclaré, un temps contrôlable. Le déterminisme en virgule flottante n’est pas promis, donc une simulation complète ne se rejoue pas non plus ici.
Ce que vous installez, et la version minimale
Le cœur est une unité ; les modules optionnels en sont d’autres, chacun avec une composition déclarée et une liste déclarée de dépendances obligatoires. Ajouter une unité ne change jamais l’interface d’une autre : un module se monte là où sa déclaration l’indique, si bien que rien n’apparaît ni ne disparaît ailleurs à cause de ce que vous avez installé à côté.
Si une unité optionnelle est référencée mais ne peut pas se charger, c’est un résultat déclaré de l’initialisation — au même endroit que le signalement d’un module désactivé. Jamais un bouchon qui, en silence, ne fait rien.
Chaque binding déclare la version minimale du runtime pour laquelle il est construit. En dessous, vous obtenez un refus à l’initialisation, pas un fonctionnement partiel : sinon, un runtime trop ancien casse à la première fonctionnalité qui lui manque, quelque part au hasard dans votre code, et en général sur la machine d’un joueur plutôt que sur la vôtre. Relever ce minimum est un changement cassant, qui suit le même processus que tous les autres.
Pour aller plus loin
- Premiers pas — la première salle (room), de bout en bout.
- Comment le SDK est construit — pourquoi il n’y a qu’une interface, et comment les pièces s’assemblent.
- Sous le capot — la couche située sous celle-ci, si vous êtes curieux.