Conception : auto-réparation du clientId en cas d’invalid_client_id (DaemonSessionClient)
- Date : 2026-06-24
- Composant :
packages/sdk-typescript—DaemonSessionClient - Dépend de : PR #5784 (
fix(daemon): Reject stale prompt client admission) — fusionnée (84745d0f0) - Statut : Implémenté (construit sur la base fusionnée de la PR #5784)
Problème
Après un redémarrage du daemon (ou un rechargement de session), l’enregistrement en mémoire des clients du daemon est effacé. Un frontend qui détient encore un ancien clientId attribué par le serveur enverra POST /session/:id/prompt avec cet id obsolète. La méthode resolveTrustedClientId du bridge ne le reconnaît pas et rejette le prompt avec InvalidClientIdError.
Incident de production observé (trace a76a31fe…, log du daemon 15:24) : le prompt a été envoyé par client_d019b847 alors que la session avait été (re)chargée sous un id différent client_ac36fac9, de sorte que le client envoyant le prompt n’a jamais été enregistré. L’UI est restée bloquée sur “处理中” indéfiniment car l’échec n’a jamais été remonté sous forme d’événement de tour terminal.
La PR #5784 corrige la partie remontée : invalid_client_id est maintenant levé au moment de l’admission afin que POST /session/:id/prompt retourne un 400 invalid_client_id synchrone (sans promptId) au lieu d’un 202 suivi d’un échec asynchrone silencieux. Cette conception ajoute la partie auto-réparation : lorsque le SDK reçoit ce 400, il s’enregistre à nouveau pour obtenir un nouveau clientId et réessaie le prompt une fois, afin que le tour se poursuive sans que l’utilisateur n’ait à le renvoyer manuellement.
Périmètre
Dans le périmètre (SDK uniquement, DaemonSessionClient) :
- Détecter
invalid_client_idlors de l’appel d’admission du prompt. - Réenregistrer le client auprès de la session (déjà restaurée) pour obtenir un nouveau
clientIdattribué par le serveur. - Réessayer le prompt une seule fois avec le nouveau
clientId.
Explicitement hors périmètre (YAGNI) :
- Reconnexion du flux SSE — reste la responsabilité existante de la couche applicative (l’application dataworks possède déjà la logique
reloadSession/reconnexion).invalid_client_idn’apparaît que lors de l’appel d’admission, jamais lors de l’attente SSE. - Auto-réparation pour les autres méthodes utilisant un
clientId(btw,shell, message en cours de tour,cancel,heartbeat). Seuleprompt()s’auto-répare. - Persistance du
clientIdentre les redémarrages du daemon.
Invariants clés (vérifiés par rapport au code source)
-
La retry est sûre car
invalid_client_idest un rejet au moment de l’admission.resolveTrustedClientIds’exécute dansbridge.sendPromptavant que le tour ne soit enregistré et avant que la route n’émette un202. Avec la PR #5784, cela lève une exception de manière synchrone →400avant l’acceptation → le prompt n’a jamais été exécuté. Réessayer ne peut donc pas exécuter le message de l’utilisateur en double. Cet invariant est le fondement même de la sûreté de la retry ; il dépend de la PR #5784. -
registerClientne lève jamais d’exception et retourne toujours un id valide. Pour unrequestedClientIdinconnu, il passe àcreateClientId()et retourne un nouveauclient_<uuid>. SeulresolveTrustedClientId(utilisé par prompt/cancel/…) lève une exception. Ainsi, un appelload/resumeretourne toujours unclientIdutilisable. -
La réponse de restauration contient toujours le
clientIdenregistré. Le chemin rapide d’entrée existante et le chemin de restauration à froid définissent tous deuxclientId: registerClient(entry, req.clientId)dans la réponse. (La note “renvoyé uniquement lorsque l’appelant a fourni un clientId” danstypes.tss’applique àHeartbeatResult, pas à la restauration.) -
Pas de fuite nette d’attach dans le scénario de redémarrage, et la correction de
close()s’améliore.resumeSessionfaitattachCount++. La décrémentation par refcount est/detach→detachClient(attachCount--+unregisterClient).close()→DELETE /session/:id→closeSessionImplest destroy-all : il valide leclientIdviaresolveTrustedClientIdpuis détruit la session (byId.delete), supprimantattachCountavec. Un redémarrage du daemon efface l’attach d’avant le redémarrage ;reattach()rétablit exactement un attach, et unclose()/redémarrage ultérieur détruit le tout — pas de fuite nette. Notez quecloseSessionImplvalide également leclientId, donc avant ce changement, unclose()post-redémarrage avec un id obsolète lèverait lui-mêmeInvalidClientIdError; après unreattach()déclenché par un prompt,this.clientIdest valide doncclose()réussit. (close()n’est pas auto-réparé en soi — hors périmètre — mais en bénéficie indirectement.) -
Le changement est inactif sans la PR #5784. Un daemon pré-#5784 retourne
202puis échec asynchrone, jamais400 invalid_client_id, donc le prédicat ne correspond jamais et l’auto-réparation ne se déclenche jamais. Opération nulle inoffensive.
Conception
Toutes les modifications sont confinées à
packages/sdk-typescript/src/daemon/DaemonSessionClient.ts.
1. isInvalidClientId(err): boolean
function isInvalidClientId(err: unknown): boolean {
return (
err instanceof DaemonHttpError &&
err.status === 400 &&
typeof err.body === 'object' &&
err.body !== null &&
(err.body as { code?: unknown }).code === 'invalid_client_id'
);
}Nécessite d’importer DaemonHttpError depuis ./DaemonHttpError.js.
2. reattach(): Promise<void> — single-flight
private reattaching?: Promise<void>;
private async reattach(): Promise<void> {
// Coalesce concurrent prompts that all observed invalid_client_id so we
// re-register exactly once (avoids orphaning extra clientIds / attachCount).
if (this.reattaching) return this.reattaching;
this.reattaching = (async () => {
// Pass no clientId so the bridge issues a fresh registration instead of
// validating the stale one. Pass workspaceCwd explicitly: restoreSession
// calls resolveWorkspaceKey(req.workspaceCwd) before the existing-entry
// fast path, and that helper throws on a non-absolute/undefined path.
const { clientId } = await this.client.resumeSession(
this.sessionId,
{ workspaceCwd: this.workspaceCwd },
undefined,
);
this.session.clientId = clientId; // only refresh clientId; leave the SSE
// cursor (lastSeenEventId) and state alone
})();
try {
await this.reattaching;
} finally {
this.reattaching = undefined;
}
}this.session est une copie superficielle et DaemonSession.clientId n’est pas readonly,
donc la mutation sur place est valide. resume (et non load) est utilisé car nous avons
seulement besoin d’un réenregistrement, pas d’une rejoue de l’historique.
3. withClientIdSelfHeal<T>(fn): Promise<T>
private async withClientIdSelfHeal<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (err) {
if (!isInvalidClientId(err)) throw err; // non-invalid_client_id: propagate
await this.reattach(); // may throw → propagate
return await fn(); // retry exactly once; if it throws
// again (incl. invalid_client_id),
// propagate — no loop
}
}4. Intégration dans prompt()
Envelopper uniquement l’appel réseau d’admission sur les deux chemins ; garder
reservePromptSlot/releaseAdmission en dehors de l’enveloppe afin que le slot local soit
réservé une seule fois et réutilisé lors de la retry :
- Chemin bloquant (
!this.subscriptionActive) :return await this.withClientIdSelfHeal(() => this.client.prompt(this.sessionId, req, signal, this.clientId)); - Chemin non bloquant :
accepted = await this.withClientIdSelfHeal(() => this.client.promptNonBlocking(this.sessionId, req, signal, this.clientId));
this.clientId est lu à l’intérieur de la closure afin que la retry récupère l’id
rafraîchi. Tout ce qui suit l’admission (l’enregistrement de _pendingPrompts et
la correspondance des événements de tour SSE par promptId) reste inchangé ; l’abonnement SSE
est indexé par sessionId, il survit donc au changement de clientId.
Gestion des erreurs
- Les erreurs autres que
invalid_client_id(ex.500,SessionNotFoundError,DaemonPendingPromptLimitError) : propagées immédiatement, pas dereattach. - Échec de
reattach()(session vraiment disparue, réseau) : propagé — l’utilisateur voit une vraie erreur au lieu d’un blocage. - Retry épuisée (la retry retourne aussi
invalid_client_id) : propagée ; limitée à une seule retry, pas de boucle. AbortSignal: l’appel enveloppé àprompt/promptNonBlockingappellethrowIfAborted()à l’entrée, donc une retry après annulation lèveAbortError. (resumeSessionn’a pas de paramètre signal ; unreattachen cours n’est pas annulable — acceptable, c’est un appel court et unique.)
Limites connues
- Cas limite rare d’éviction individuelle : si un
clientIdest évincé alors que la session reste en mémoire (révocation de fuite /client_evicted),reattach()ajoute un attach supplémentaire (attachCount++) sans/detachcorrespondant. Commeclose()est destroy-all, la seule fenêtre de fuite est une session abandonnée sansclose()explicite et qui est empêchée d’être GC pour inactivité par leattachCountbloqué (limité à une session). L’incident réaliste est le cas de redémarrage du daemon, qui est propre. Documenté plutôt que contourné par ingénierie.
Tests (TDD)
Utiliser le harnais recordingFetch existant dans
packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts, en interceptant par
URL via un vrai DaemonClient (exerce le vrai mappage failOnError →
DaemonHttpError).
- Auto-réparation non bloquante : premier
POST /session/s-1/prompt→400 {code:'invalid_client_id'};POST /session/s-1/resume→ nouveauclientId: 'client-2'; deuxième prompt →202. Assertion : le prompt résout, la deuxième requête de prompt portex-qwen-client-id: client-2, resume appelé une fois. - Auto-réparation bloquante (
subscriptionActivefaux) : idem, via le chemin bloquant deprompt(200/202+ tour terminé lors de la retry). - Retry limitée : prompt →
400 invalid_client_iddeux fois → l’erreur est propagée (assertion : resume appelé une fois, l’erreur estDaemonHttpErrorinvalid_client_id). - Erreur non-invalide non réessayée : prompt →
500→ propagé immédiatement,resumejamais appelé. - Propagation de l’échec de reattach : prompt →
400 invalid_client_id; resume →404/500→ cette erreur est propagée. - Single-flight : deux appels
prompt()simultanés obtiennent tous deux400 invalid_client_id→resumeappelé exactement une fois ; les deux retries utilisent le nouvel id.