Diagnostiquer une fuite d’état entre les salles

CloudflareBeginner
Pratiquer maintenant

Introduction

Le nom d’un Durable Object fait partie du modèle de données d’une application. Les appels qui utilisent le même nom atteignent le même objet logique et sa base de données SQLite ; des noms différents sélectionnent des unités de coordination différentes. Une régression de routage peut donc exposer l’état d’une salle via l’URL d’une autre salle, même lorsque la classe Durable Object et son code de stockage sont corrects.

Dans ce lab, vous allez déployer un petit journal de salles contenant des historiques intacts pour planning et support, reproduire une version défectueuse qui envoie toutes les salles vers l’objet planning, puis utiliser un diagnostic de routage pour trouver l’incohérence. Vous corrigerez uniquement la correspondance des noms, redéploierez l’application et prouverez que les deux historiques d’origine ont été conservés. Un outil WebSocket fourni effectuera ensuite des mises à jour simultanées, des déconnexions et des reconnexions, puis vérifiera que les nouvelles salles restent isolées.

Si vous avez commencé directement ce cours, suivez d’abord Connect LabEx to Your Cloudflare Account. Ce lab vous apprend à utiliser le terminal de la machine virtuelle LabEx, l’autorisation de l’appareil avec Wrangler, la confirmation du compte et la configuration explicite de l’identifiant de compte utilisées ici. Cette nouvelle machine virtuelle doit encore être autorisée.

Autoriser la machine virtuelle et déclarer l’espace de noms des salles

Dans cette étape, vous allez autoriser cette nouvelle machine virtuelle, confirmer le compte d’apprentissage dédié et déclarer un espace de noms Durable Object reposant sur SQLite.

cd /home/labex/project/room-routing
npx wrangler --version
npx wrangler login --device --browser=false

Wrangler doit afficher la version 4.132.0. Ouvrez l’URL Cloudflare affichée dans le navigateur, saisissez le code court, confirmez le compte d’apprentissage voulu et autorisez-le. L’autorisation de l’appareil donne à cette machine virtuelle l’accès nécessaire sans envoyer votre mot de passe au terminal.

Lisez uniquement les champs d’identité non sensibles, sélectionnez le compte confirmé par son nom, puis créez un nom de Worker temporaire :

WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"
RUN="labex-c10-o07-$(openssl rand -hex 6)"
printf '%s\n' "$RUN" | tee .labex/run-name
cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/index.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true, "head_sampling_rate": 1 },
  "durable_objects": { "bindings": [
    { "name": "ROOMS", "class_name": "RoomJournal" }
  ] },
  "exports": {
    "RoomJournal": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

ROOMS est une liaison d’espace de noms : elle peut adresser de nombreux objets RoomJournal. Le nom choisi par l’application et transmis à getByName() détermine la base SQLite de l’objet concerné et les connexions actives qui recevront l’appel.

Créer un journal avec des noms d’objets explicites

Dans cette étape, vous allez implémenter la classe avec état et conserver la sélection de l’identité dans une petite fonction de routage unique. Cette séparation est importante pendant le diagnostic : le stockage peut fonctionner correctement tandis que l’appelant sélectionne le mauvais objet.

Créez d’abord le convertisseur correct. Un nom de salle validé constitue déjà un nom d’objet stable et déterministe :

cat > src/router.js <<'JS'
export function objectNameFor(room) {
  return room;
}
JS
cat > test/router.test.mjs <<'JS'
import test from "node:test";
import assert from "node:assert/strict";
import { objectNameFor } from "../src/router.js";

test("each validated room keeps its own object identity", () => {
  assert.equal(objectNameFor("planning"), "planning");
  assert.equal(objectNameFor("support"), "support");
  assert.notEqual(objectNameFor("planning"), objectNameFor("support"));
});
JS

Créez le Worker et le Durable Object. ctx.id.name indique le nom stable utilisé pour atteindre cet objet. La page journalise uniquement les noms de salles synthétiques demandés et sélectionnés ; le texte du journal est volontairement exclu des journaux.

cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
import { objectNameFor } from "./router.js";

const ROOM = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
const EVENT = /^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/;
const json = (value, status = 200) => Response.json(value, { status });
const safeRoom = value => ROOM.test(value || "") ? value : null;

export class RoomJournal extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS events (
        sequence INTEGER PRIMARY KEY AUTOINCREMENT,
        event_id TEXT NOT NULL UNIQUE,
        text TEXT NOT NULL
      )`);
    });
  }

  state() {
    return {
      objectName: this.ctx.id.name,
      events: this.ctx.storage.sql.exec(
        "SELECT sequence, event_id AS eventId, text FROM events ORDER BY sequence"
      ).toArray()
    };
  }

  append(eventId, text) {
    if (!EVENT.test(eventId || "") || typeof text !== "string" || text.length < 1 || text.length > 80) {
      throw new Error("invalid_event");
    }
    this.ctx.storage.sql.exec("INSERT OR IGNORE INTO events (event_id, text) VALUES (?, ?)", eventId, text);
    return this.state();
  }

  async fetch(request) {
    if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return json({ error: "upgrade_required" }, 426);
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);
    this.ctx.acceptWebSocket(server);
    server.send(JSON.stringify({ type: "ready", ...this.state() }));
    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(socket, raw) {
    try {
      const message = JSON.parse(raw);
      if (message.type !== "append") throw new Error("invalid_event");
      const state = this.append(message.eventId, message.text);
      const frame = JSON.stringify({ type: "event", ...state });
      for (const peer of this.ctx.getWebSockets()) peer.send(frame);
    } catch {
      socket.send(JSON.stringify({ type: "error", error: "invalid_event" }));
    }
  }
}

async function roomState(env, room) {
  return env.ROOMS.getByName(objectNameFor(room)).state();
}

function inspectPage(planning, support) {
  const rows = [planning, support].map(([requested, state]) => `<tr><td>${requested}</td><td>${state.objectName}</td><td>${state.events.map(x => x.eventId).join(", ")}</td></tr>`).join("");
  return `<!doctype html><html lang="en"><meta charset="utf-8"><title>Room routing inspector</title>
  <style>body{font:18px system-ui;max-width:900px;margin:48px auto;color:#17212b}h1{color:#5b8c00}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccd5df;padding:14px;text-align:left}th{background:#eef7dc}.ok{padding:12px;background:#eef7dc;border-left:5px solid #78aa00}</style>
  <h1>Room routing inspector</h1><p class="ok">Each requested room resolves to the matching Durable Object name.</p>
  <table><thead><tr><th>Requested room</th><th>Object name</th><th>Preserved event IDs</th></tr></thead><tbody>${rows}</tbody></table></html>`;
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === "/inspect") {
      const states = await Promise.all(["planning", "support"].map(async room => [room, await roomState(env, room)]));
      return new Response(inspectPage(...states), { headers: { "content-type": "text/html; charset=utf-8" } });
    }
    const debug = url.pathname.match(/^\/debug\/route\/([^/]+)$/);
    if (debug) {
      const room = safeRoom(debug[1]);
      if (!room) return json({ error: "invalid_room" }, 400);
      return json({ requestedRoom: room, objectName: objectNameFor(room) });
    }
    const match = url.pathname.match(/^\/rooms\/([^/]+)\/(events|connect)$/);
    if (!match) return json({ error: "not_found" }, 404);
    const room = safeRoom(match[1]);
    if (!room) return json({ error: "invalid_room" }, 400);
    const objectName = objectNameFor(room);
    console.log(JSON.stringify({ event: "routing_decision", requestedRoom: room, objectName, operation: match[2] }));
    const stub = env.ROOMS.getByName(objectName);
    if (match[2] === "connect") return stub.fetch(request);
    if (request.method === "GET") return json(await stub.state());
    if (request.method === "POST") {
      try {
        const body = await request.json();
        return json(await stub.append(body.eventId, body.text), 201);
      } catch (error) {
        return json({ error: error.message === "invalid_event" ? "invalid_event" : "invalid_json" }, 400);
      }
    }
    return json({ error: "method_not_allowed" }, 405);
  }
};
JS
npm test

Le test vérifie directement la limite d’identité. Le Durable Object utilise le nom fourni par son environnement d’exécution pour le diagnostic et enregistre les lignes du journal dans SQLite avant de les renvoyer.

Déployer deux historiques de salles intacts

Dans cette étape, vous allez d’abord déployer la version saine, puis créer un événement reconnaissable dans chaque salle. Ces lignes constituent la preuve de conservation : la réparation ne sera réussie que si les deux lignes réapparaissent depuis leurs objets d’origine.

rm -f .labex/deploy.log .labex/app-url .labex/baseline.json
npx wrangler deploy | tee .labex/deploy.log
APP_URL="$(grep -Eo 'https://[^ ]+\.workers\.dev' .labex/deploy.log | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL" | tee .labex/app-url
for attempt in $(seq 1 30); do READY="$(curl --silent "$APP_URL/debug/route/planning" || true)"; test "$(jq -r '.objectName // empty' <<<"$READY" 2>/dev/null)" = planning && break; sleep 2; done
test "$(jq -r .objectName <<<"$READY")" = planning
sleep 5
curl --silent --fail -X POST "$APP_URL/rooms/planning/events" -H 'content-type: application/json' --data '{"eventId":"plan-start","text":"Planning kickoff"}' >/dev/null
curl --silent --fail -X POST "$APP_URL/rooms/support/events" -H 'content-type: application/json' --data '{"eventId":"support-start","text":"Support handoff"}' >/dev/null
jq -n --argjson planning "$(curl --silent --fail "$APP_URL/rooms/planning/events")" --argjson support "$(curl --silent --fail "$APP_URL/rooms/support/events")" '{planning:$planning,support:$support}' | tee .labex/baseline.json

Les deux champs objectName doivent être différents. planning doit contenir uniquement plan-start, tandis que support doit contenir uniquement support-start. Le nom du Worker est temporaire, mais ces historiques d’objets doivent survivre à la régression, puis à la réparation de la version.

Reproduire et analyser la version défectueuse

Dans cette étape, vous allez simuler une régression fournie avec le lab. La fonction défectueuse ignore son argument et renvoie toujours planning. L’exécution du test d’identité doit échouer ; l’enregistrement de cet échec contrôlé rend le défaut observable avant le déploiement.

cp fixtures/router-bug.js src/router.js
rm -f .labex/bug-test.log .labex/bug.json
set -o pipefail
if npm test 2>&1 | tee .labex/bug-test.log; then TEST_STATUS=0; else TEST_STATUS=$?; fi
set +o pipefail
printf '%s\n' "$TEST_STATUS" > .labex/bug-test-status
test "$TEST_STATUS" -ne 0
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
for attempt in $(seq 1 30); do
  BUG_ROUTE="$(curl --silent "$APP_URL/debug/route/support" || true)"
  BUG_READ="$(curl --silent "$APP_URL/rooms/support/events" || true)"
  test "$(jq -r '.objectName // empty' <<<"$BUG_ROUTE" 2>/dev/null)" = planning && test "$(jq -r '.objectName // empty' <<<"$BUG_READ" 2>/dev/null)" = planning && break
  sleep 2
done
test "$(jq -r .objectName <<<"$BUG_ROUTE")" = planning
test "$(jq -r .objectName <<<"$BUG_READ")" = planning
jq -n \
  --argjson planningRoute "$(curl --silent --fail "$APP_URL/debug/route/planning")" \
  --argjson supportRoute "$BUG_ROUTE" \
  --argjson supportRead "$BUG_READ" \
  '{planningRoute:$planningRoute,supportRoute:$supportRoute,supportRead:$supportRead}' | tee .labex/bug.json

Le diagnostic distingue la salle demandée du nom de l’objet sélectionné. Une requête pour support indique maintenant objectName: planning, et sa lecture expose plan-start. Vous n’avez ni supprimé ni écrasé l’objet support d’origine ; la mauvaise version a simplement cessé de l’adresser.

Corriger le convertisseur et prouver l’isolation après reconnexion

Dans cette étape, vous allez corriger uniquement la correspondance d’identité. Aucune réinitialisation du stockage ni nouvelle lecture des données n’est nécessaire, car les objets nommés d’origine existent toujours.

cat > src/router.js <<'JS'
export function objectNameFor(room) {
  return room;
}
JS
npm test
cat > tools/isolation.mjs <<'JS'
import WebSocket from "ws";
const [base, prefix] = process.argv.slice(2);
const wsBase = base.replace(/^http/, "ws");
const rooms = [`${prefix}-planning`, `${prefix}-support`];
const open = room => new Promise((resolve, reject) => {
  const ws = new WebSocket(`${wsBase}/rooms/${room}/connect`);
  const inbox = [];
  ws.on("message", raw => { const value = JSON.parse(raw); inbox.push(value); if (value.type === "ready") resolve({ ws, inbox, ready:value }); });
  ws.on("error", reject);
});
const waitFor = (client, eventId) => new Promise((resolve, reject) => {
  const timer = setTimeout(() => reject(new Error("event timeout")), 5000);
  const inspect = value => { if (value.type === "event" && value.events.some(x => x.eventId === eventId)) { clearTimeout(timer); client.ws.off("message", listener); resolve(value); } };
  const listener = raw => inspect(JSON.parse(raw));
  client.ws.on("message", listener); client.inbox.forEach(inspect);
});
const close = client => new Promise(resolve => { client.ws.once("close", resolve); client.ws.close(1000, "reconnect"); });
const [planning, support] = await Promise.all(rooms.map(open));
planning.ws.send(JSON.stringify({ type:"append", eventId:`${prefix}-plan`, text:"Plan update" }));
support.ws.send(JSON.stringify({ type:"append", eventId:`${prefix}-support`, text:"Support update" }));
await Promise.all([waitFor(planning, `${prefix}-plan`), waitFor(support, `${prefix}-support`)]);
await Promise.all([close(planning), close(support)]);
const [planningAgain, supportAgain] = await Promise.all(rooms.map(open));
const result = { planning:planningAgain.ready, support:supportAgain.ready };
console.log(JSON.stringify(result, null, 2));
await Promise.all([close(planningAgain), close(supportAgain)]);
JS
rm -f .labex/repaired.json .labex/reconnect.json
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
for attempt in $(seq 1 30); do REPAIRED_READY="$(curl --silent "$APP_URL/debug/route/support" || true)"; test "$(jq -r '.objectName // empty' <<<"$REPAIRED_READY" 2>/dev/null)" = support && break; sleep 2; done
test "$(jq -r .objectName <<<"$REPAIRED_READY")" = support
sleep 5
jq -n --argjson planning "$(curl --silent --fail "$APP_URL/rooms/planning/events")" --argjson support "$(curl --silent --fail "$APP_URL/rooms/support/events")" '{planning:$planning,support:$support}' | tee .labex/repaired.json
node tools/isolation.mjs "$APP_URL" cloud | tee .labex/reconnect.json

La lecture réparée retrouve les deux identifiants d’événement d’origine dans leurs objets respectifs. L’outil WebSocket met ensuite à jour simultanément deux nouveaux objets, ferme les deux connexions et se reconnecte. Chaque trame ready contient uniquement son propre événement, ce qui prouve que le problème venait du routage et non d’une réinitialisation de la base de données.

Inspecter et redéployer le service réparé

Dans cette étape, vous allez relier les preuves d’exécution à des vues accessibles aux débutants dans le navigateur et le Dashboard. Ouvrez l’URL enregistrée dans .labex/app-url, suivie de /inspect. Le message vert et le tableau doivent afficher planning → planning, support → support ainsi que les deux identifiants d’événement conservés.

L’inspecteur réparé associe chaque salle à l’objet correspondant et à son historique conservé

Ouvrez Workers & Pages, sélectionnez le nom exact du Worker indiqué dans .labex/run-name, puis ouvrez Bindings. ROOMS doit être associé à RoomJournal.

La liaison ROOMS pointe vers le Durable Object RoomJournal

Ouvrez Durable Objects, sélectionnez <your-worker>_RoomJournal, puis vérifiez Storage: SQL. Un espace de noms peut contenir de nombreux objets nommés ; le nom sélectionne l’objet isolé qui s’y trouve.

L’espace de noms RoomJournal utilise le stockage SQL

Revenez au Worker dans Workers & Pages, ouvrez Observability, puis recherchez les événements enregistrés avec routing_decision. Développez un événement correspondant à une opération sur une salle. Cette décision est enregistrée par le Worker sans état avant son appel au Durable Object ; elle apparaît donc dans les journaux du Worker et non dans ceux de l’espace de noms. Les champs non sensibles doivent afficher le même nom de salle synthétique demandé et sélectionné, sans le texte du journal.

Une décision de routage structurée montre la correspondance d’identité réparée

Enfin, redéployez le code sans le modifier et relisez les salles d’origine :

npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/rooms/planning/events" | jq
curl --silent --fail "$APP_URL/rooms/support/events" | jq

Les deux historiques d’origine sont toujours présents. Un déploiement inchangé ne crée pas de nouvelles identités d’objet, car les mêmes noms validés sélectionnent toujours les mêmes entrées de l’espace de noms.

Supprimer l’espace de noms du journal de salles

Dans cette étape, vous allez supprimer uniquement le Worker de ce lab et l’espace de noms généré, tant que la machine virtuelle est encore autorisée. Le marqueur déclaratif supprime l’espace de noms de la classe avant que Wrangler ne supprime le script.

RUN="$(cat .labex/run-name)"
case "$RUN" in labex-c10-o07-*) ;; *) echo "Unexpected Worker name" >&2; exit 1;; esac
cat > src/cleanup.js <<'JS'
export default { fetch() { return Response.json({ status: "cleanup" }, { status: 410 }); } };
JS
ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/cleanup.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "exports": { "RoomJournal": { "type": "durable-object", "state": "deleted" } }
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc

Vérifiez que l’invite affiche exactement $RUN, saisissez y et attendez le message Successfully deleted. Gardez la machine virtuelle autorisée pour effectuer la vérification indépendante de l’absence :

npx wrangler whoami --json | jq '{loggedIn, authType}'

Le JSON doit contenir "loggedIn": true ; une erreur réseau ou d’authentification ne prouve pas la suppression.

Révoquer l’autorisation Wrangler de cette machine virtuelle

Dans cette étape, vous allez supprimer l’autorisation OAuth de cette machine virtuelle uniquement après avoir vérifié la suppression de manière indépendante :

npx wrangler logout
npx wrangler whoami --json

Le JSON final doit contenir "loggedIn": false. Votre compte d’apprentissage reste connecté dans le navigateur.

Résumé

Vous avez diagnostiqué un défaut Durable Object à la limite du routage des identités, sans réinitialiser un stockage sain. Une version défectueuse contrôlée a prouvé que support sélectionnait l’objet planning ; le diagnostic de routage a rendu visibles les noms demandés et sélectionnés. Le rétablissement de la correspondance directe des noms a immédiatement récupéré les deux historiques SQLite d’origine. Des mises à jour WebSocket simultanées, des déconnexions, des reconnexions et un redéploiement inchangé ont ensuite prouvé que les nouvelles salles et les salles existantes restaient isolées. Enfin, vous avez inspecté le déploiement réparé et supprimé ses ressources temporaires précises avant de vous déconnecter.