From 15ad3e35d16d0bc94bb52aeb960c4f5a7bff24f1 Mon Sep 17 00:00:00 2001 From: b84330808 Date: Mon, 2 Nov 2026 01:38:30 +0900 Subject: [PATCH] fix(sio-client): do not treat the ack as an error when retries is used without ackTimeout With the `retries` option, the queue wraps the user callback in a handler whose first argument is the error. Without `ackTimeout` (or `timeout()`), that handler was registered as-is, so it received the server response directly: `cb(42)` from the server was read as an error, the packet was sent again until the retries were exhausted, and the user callback got `42` as the error. `emitWithAck()` rejected with the response value. The queue handler is now flagged with `withError`, so the response is always prepended with `null`, like when `ackTimeout` is set. Co-Authored-By: Claude Opus 5.5 --- packages/socket.io-client/lib/socket.ts | 7 +++-- packages/socket.io-client/test/retry.ts | 35 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/socket.io-client/lib/socket.ts b/packages/socket.io-client/lib/socket.ts index cc8ff87df0..0d57a61928 100644 --- a/packages/socket.io-client/lib/socket.ts +++ b/packages/socket.io-client/lib/socket.ts @@ -544,7 +544,7 @@ export class Socket< flags: Object.assign({ fromQueue: true }, this.flags), }; - args.push((err, ...responseArgs) => { + const onResponse = (err, ...responseArgs) => { if (packet !== this._queue[0]) { return debug("packet [%d] already acknowledged", packet.id); } @@ -570,7 +570,10 @@ export class Socket< } packet.pending = false; return this._drainQueue(); - }); + }; + // the first argument is always the error (or null), even without the "ackTimeout" option + onResponse.withError = true; + args.push(onResponse); this._queue.push(packet); this._drainQueue(); diff --git a/packages/socket.io-client/test/retry.ts b/packages/socket.io-client/test/retry.ts index 9ceca208b1..13368877ed 100644 --- a/packages/socket.io-client/test/retry.ts +++ b/packages/socket.io-client/test/retry.ts @@ -111,6 +111,41 @@ describe("retry", () => { }); }); + it("should not treat the acknowledgement as an error without ackTimeout", () => { + return wrap((done) => { + const socket = io(BASE_URL, { + forceNew: true, + retries: 3, + }); + + const sent: string[] = []; + + socket.io.engine.on("packetCreate", ({ data }) => { + sent.push(data); + }); + + socket.emit("echo", 1, (err, val) => { + expect(err).to.be(null); + expect(val).to.eql(1); + expect(sent).to.eql(["0", '20["echo",1]']); + + success(done, socket); + }); + }); + }); + + it("should resolve emitWithAck() without ackTimeout", async () => { + const socket = io(BASE_URL, { + forceNew: true, + retries: 3, + }); + + const val = await socket.emitWithAck("echo", 2); + expect(val).to.eql(2); + + socket.disconnect(); + }); + it("should not emit a packet twice in the 'connect' handler", () => { return wrap((done) => { const socket = io(BASE_URL, {