From b961be4234c11cc98404f9b79cfcbb1f9f352356 Mon Sep 17 00:00:00 2001 From: marwan562 Date: Sun, 23 Aug 2026 18:24:29 +0300 Subject: [PATCH] fix: allow session recovery even if no event was received Before that fix, the session could only be recovered if the client provided the offset of the last event it had received. But when no event was received before the disconnection, the offset was undefined, which prevented the restoration of the session (id, rooms and data). The offset is now optional in Adapter#restoreSession: when it is not provided, the session is restored without replaying any event. Fixes #5538 --- .../lib/in-memory-adapter.ts | 26 +++--- packages/socket.io-adapter/test/index.ts | 43 +++++++++ .../test/connection-state-recovery.ts | 26 ++++++ packages/socket.io/lib/namespace.ts | 6 +- .../test/connection-state-recovery.ts | 87 +++++++++++++++++++ 5 files changed, 173 insertions(+), 15 deletions(-) diff --git a/packages/socket.io-adapter/lib/in-memory-adapter.ts b/packages/socket.io-adapter/lib/in-memory-adapter.ts index cf178170e0..07aee0de26 100644 --- a/packages/socket.io-adapter/lib/in-memory-adapter.ts +++ b/packages/socket.io-adapter/lib/in-memory-adapter.ts @@ -387,11 +387,11 @@ export class Adapter extends EventEmitter { /** * Restore the session and find the packets that were missed by the client. * @param pid - * @param offset + * @param offset - the offset of the last packet received by the client, if any */ public restoreSession( pid: PrivateSessionId, - offset: string, + offset?: string, ): Promise { return null; } @@ -444,7 +444,7 @@ export class SessionAwareAdapter extends Adapter { override restoreSession( pid: PrivateSessionId, - offset: string, + offset?: string, ): Promise { const session = this.sessions.get(pid); if (!session) { @@ -458,16 +458,18 @@ export class SessionAwareAdapter extends Adapter { this.sessions.delete(pid); return null; } - const index = this.packets.findIndex((packet) => packet.id === offset); - if (index === -1) { - // the offset may be too old - return null; - } const missedPackets = []; - for (let i = index + 1; i < this.packets.length; i++) { - const packet = this.packets[i]; - if (shouldIncludePacket(session.rooms, packet.opts)) { - missedPackets.push(packet.data); + if (offset !== undefined) { + const index = this.packets.findIndex((packet) => packet.id === offset); + if (index === -1) { + // the offset may be too old + return null; + } + for (let i = index + 1; i < this.packets.length; i++) { + const packet = this.packets[i]; + if (shouldIncludePacket(session.rooms, packet.opts)) { + missedPackets.push(packet.data); + } } } return Promise.resolve({ diff --git a/packages/socket.io-adapter/test/index.ts b/packages/socket.io-adapter/test/index.ts index 285ae66f02..96ff3881ad 100644 --- a/packages/socket.io-adapter/test/index.ts +++ b/packages/socket.io-adapter/test/index.ts @@ -551,5 +551,48 @@ describe("socket.io-adapter", () => { expect(session).to.be(null); }); + + it("should restore a known session without offset", async () => { + const adapter = new SessionAwareAdapter({ + server: { + encoder: { + encode(packet) { + return packet; + }, + }, + opts: { + connectionStateRecovery: { + maxDisconnectionDuration: 5000, + }, + }, + }, + }); + + adapter.persistSession({ + sid: "abc", + pid: "def", + data: "ghi", + rooms: ["r1", "r2"], + }); + + adapter.broadcast( + { + nsp: "/", + type: 2, + data: ["hello"], + }, + { + rooms: new Set(), + except: new Set(), + }, + ); + + const session = await adapter.restoreSession("def"); + + expect(session).to.not.be(null); + expect(session.sid).to.eql("abc"); + expect(session.pid).to.eql("def"); + expect(session.missedPackets).to.eql([]); + }); }); }); diff --git a/packages/socket.io-client/test/connection-state-recovery.ts b/packages/socket.io-client/test/connection-state-recovery.ts index 42c826b0ea..04dcc69f6f 100644 --- a/packages/socket.io-client/test/connection-state-recovery.ts +++ b/packages/socket.io-client/test/connection-state-recovery.ts @@ -27,4 +27,30 @@ describe("connection state recovery", () => { }); }); }); + + it("should restore session even if no event was received", () => { + return wrap((done) => { + const socket = io(BASE_URL, { + forceNew: true, + reconnectionDelay: 10, + }); + + expect(socket.recovered).to.eql(false); + + let id: string; + + socket.on("connect", () => { + if (!id) { + // first connection: no event has been exchanged yet + id = socket.id; + + socket.io.engine.close(); + } else { + expect(socket.id).to.eql(id); // means that the reconnection was successful + expect(socket.recovered).to.eql(true); // means that the reconnection was successful + done(); + } + }); + }); + }); }); diff --git a/packages/socket.io/lib/namespace.ts b/packages/socket.io/lib/namespace.ts index 72df7301e3..f38dbc8178 100644 --- a/packages/socket.io/lib/namespace.ts +++ b/packages/socket.io/lib/namespace.ts @@ -384,12 +384,12 @@ export class Namespace< auth: Record, ) { const sessionId = auth.pid; - const offset = auth.offset; + // note: the offset may be undefined, if the client has not yet received any event + const offset = typeof auth.offset === "string" ? auth.offset : undefined; if ( // @ts-ignore this.server.opts.connectionStateRecovery && - typeof sessionId === "string" && - typeof offset === "string" + typeof sessionId === "string" ) { let session; try { diff --git a/packages/socket.io/test/connection-state-recovery.ts b/packages/socket.io/test/connection-state-recovery.ts index c0dcbf0703..a3528425a2 100644 --- a/packages/socket.io/test/connection-state-recovery.ts +++ b/packages/socket.io/test/connection-state-recovery.ts @@ -113,6 +113,93 @@ describe("connection state recovery", () => { io.close(); }); + it("should restore session even if the client did not receive any event", async () => { + const httpServer = createServer().listen(0); + const io = new Server(httpServer, { + connectionStateRecovery: {}, + }); + + io.once("connection", (socket) => { + expect(socket.recovered).to.eql(false); + + socket.join("room1"); + socket.data.foo = "bar"; + }); + + // Engine.IO handshake + const eioSid = await eioHandshake(httpServer); + + // Socket.IO handshake (without any prior event, hence without any offset) + await eioPush(httpServer, eioSid, "40"); + const handshakeBody = await eioPoll(httpServer, eioSid); + + expect(handshakeBody.startsWith("40")).to.be(true); + + const handshake = JSON.parse(handshakeBody.substring(2)); + + expect(handshake.sid).to.not.be(undefined); + expect(handshake.pid).to.not.be(undefined); + + await eioPush(httpServer, eioSid, "1"); // close + + const newSid = await eioHandshake(httpServer); + + const [socket] = await Promise.all([ + waitFor(io, "connection"), + eioPush(httpServer, newSid, `40{"pid":"${handshake.pid}"}`), + ]); + + expect(socket.id).to.eql(handshake.sid); + expect(socket.recovered).to.eql(true); + + expect(socket.rooms.has(socket.id)).to.eql(true); + expect(socket.rooms.has("room1")).to.eql(true); + + expect(socket.data.foo).to.eql("bar"); + + const payload = await eioPoll(httpServer, newSid); + expect(payload).to.eql( + `40{"sid":"${handshake.sid}","pid":"${handshake.pid}"}`, + ); + + io.close(); + }); + + it("should restore session even if the provided offset is not a string", async () => { + const httpServer = createServer().listen(0); + const io = new Server(httpServer, { + connectionStateRecovery: {}, + }); + + io.once("connection", (socket) => { + socket.join("room1"); + }); + + // Engine.IO handshake + const eioSid = await eioHandshake(httpServer); + + // Socket.IO handshake + await eioPush(httpServer, eioSid, "40"); + const handshakeBody = await eioPoll(httpServer, eioSid); + const handshake = JSON.parse(handshakeBody.substring(2)); + + await eioPush(httpServer, eioSid, "1"); // close + + const newSid = await eioHandshake(httpServer); + + const [socket] = await Promise.all([ + waitFor(io, "connection"), + eioPush(httpServer, newSid, `40{"pid":"${handshake.pid}","offset":123}`), + ]); + + expect(socket.id).to.eql(handshake.sid); + expect(socket.recovered).to.eql(true); + expect(socket.rooms.has("room1")).to.eql(true); + + await eioPoll(httpServer, newSid); // drain buffer + io.close(); + }); + it("should not run middlewares upon recovery by default", async () => { const httpServer = createServer().listen(0); const io = new Server(httpServer, {