CGY-3852 fix: re-buffer sendMessage on disconnect-before-ack (CGY-3852) - #77
Conversation
…erval overload flake (CGY-3852) npm test deterministically failed on a cold jest cache with a TS2322 error at socket-client.ts:119 (setInterval / NodeJS.Timeout), caused by an ambiguity between lib.dom's and @types/node's global setInterval overloads that only surfaces on ts-jest's per-file type-check pass. That line is pre-existing reconnect-interval code, untouched by the Task 3 sendMessage fix. npm run build (plain tsc) was already clean regardless of cache state, so isolatedModules: true defers to it as the authoritative type check and removes ts-jest's redundant, flake-prone one.
… leak, doc, packaging) - Gate disconnect-before-ack re-buffering on having observed at least one successful processInput ack on this connection (hasConfirmedAck), so a disconnect against a non-acking endpoint no longer duplicates a message the server already processed. - Clear messageBuffer in switchSession() after disconnect() so old-session messages can't leak into the new session's flush. - Rewrite the emitWithAck doc comment to cover both the pre-existing server->client ack meaning and the new client->server meaning. - Exclude src/**/*.test.ts from tsconfig so tsc stops emitting the test file into lib/, and stop shipping jest.config.js via .npmignore.
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
There was a problem hiding this comment.
Pull request overview
Updates SocketClient.sendMessage() to use socket.io’s ack-with-timeout mechanism (when emitWithAck is enabled) and re-buffer/resend messages when the connection drops before an ack arrives, plus introduces a new Jest test suite and packaging safeguards to keep test/tooling files out of the published npm tarball.
Changes:
- Add ack-with-timeout emission + disconnect-before-ack re-buffering logic gated by first successful ack observation.
- Add Jest + ts-jest test harness and a
SocketClient#sendMessagetest suite. - Prevent tests/tooling from being compiled/published (tsconfig exclude +
.npmignoreupdates).
Reviewed changes
Copilot reviewed 5 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.json | Excludes src/**/*.test.ts from tsc output to avoid generating/publishing test artifacts. |
| src/socket-client.ts | Implements ack-based send + conditional re-buffer-on-disconnect and clears buffer on switchSession(). |
| src/socket-client.test.ts | Adds Jest tests for sendMessage ack gating, resend-on-disconnect, and emitWithAck: false behavior. |
| src/interfaces/options.ts | Clarifies emitWithAck semantics (both server→client and client→server acknowledgement behavior). |
| package.json | Adds jest test script and Jest/ts-jest dev dependencies. |
| package-lock.json | Locks newly added Jest/ts-jest dependency tree. |
| jest.config.js | Configures Jest to run TS tests via ts-jest. |
| .npmignore | Excludes Jest config from published package. |
| .gitignore | Ignores .worktrees/ and normalizes existing patterns. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| this.socket | ||
| .timeout(SocketClient.PROCESS_INPUT_ACK_TIMEOUT_MS) | ||
| .emit("processInput", payload, (err: Error | null) => { | ||
| if (err) { | ||
| if (!this.connected && this.hasConfirmedAck) { | ||
| console.log(`[SocketClient] Message was not acknowledged before the connection dropped, re-buffering it: ${err.message}`); | ||
| this.messageBuffer.push({ text, data }); | ||
| } | ||
| } else { | ||
| this.hasConfirmedAck = true; | ||
| } | ||
| }); |
| * ack "processInput", the message may already have been | ||
| * received and processed before an unrelated disconnect - | ||
| * re-buffering it then would duplicate it. We only have | ||
| * positive evidence it's safe to re-buffer once we've seen | ||
| * this endpoint successfully ack a message at least once on | ||
| * this connection, hence the `hasConfirmedAck` gate below. |
| if (!this.connected && this.hasConfirmedAck) { | ||
| console.log(`[SocketClient] Message was not acknowledged before the connection dropped, re-buffering it: ${err.message}`); | ||
| this.messageBuffer.push({ text, data }); | ||
| } |
| expect(timeoutSpy).not.toHaveBeenCalled(); | ||
| expect(fakeSocket.sentProcessInputs.map(m => m.text)).toEqual(["hello"]); | ||
| }); | ||
| }); |
Dmitrii Ostasevich (kwinto)
left a comment
There was a problem hiding this comment.
Review — verified by running the code
I installed this branch, ran npm test (5/5 pass), and wrote two probe specs against the branch to check success criteria that the suite does not cover. Good, careful work: the disconnect() → re-buffer → switchSession() ordering is subtle and correct (socket.io-client's _clearAcks() fires pending .timeout() acks synchronously inside disconnect(), so clearing the buffer after this.disconnect() really is required — the comment earns its place). Adding a test framework from scratch is a clear win.
Three substantive findings, one of which I think is blocking.
1. 🔴 This introduces a new silent message-loss path — the exact failure class the ticket is about
switchSession() now unconditionally does this.messageBuffer = []. The comment justifies it in terms of an in-flight message re-buffered by the disconnect() above, but the clear is not scoped to that — it also discards messages the user typed while already offline, which were buffered by the normal (pre-existing) buffering path and which the UI has already rendered as sent.
Probe against this branch:
PROBE P1 delivered = []
✓ P1: a message buffered while offline is SILENTLY DROPPED by switchSession (21 ms)
Reachable from Webchat today: "Start new conversation" (handleStartConversation) and "Delete conversation" both dispatch switchSession() on their own. Sequence: connection drops → user types → message buffered, shown as sent → user hits "Start new conversation" → message vanishes with no delivery and no UI signal.
Before this PR that message would have been flushed into the new session — wrong session, but delivered. So this trades "delivered to the wrong session" for "silently dropped", inside a PR whose success criteria open with "must … be resent … instead of being silently lost while the UI already shows it as sent".
Suggestion: only drop what disconnect() just re-buffered, rather than the whole queue — e.g. snapshot messageBuffer.length before this.disconnect() and truncate back to it, or have the ack error path push to a separate inFlightRequeue list that switchSession() clears while leaving user-buffered messages intact. If dropping everything really is intended, it needs to be an explicit, documented product decision, and ideally surfaced to the consumer rather than silent.
2. 🟠 hasConfirmedAck is per-client, not per-connection — and it is weakest in exactly the scenario this PR targets
The field doc says it is deliberately never reset (a property of the endpoint). But the rationale comment inside sendMessage says "once we've seen this endpoint successfully ack a message at least once on this connection", and both new test names say "on this connection". The code does not do that:
PROBE P2 resent on connection #3 = ["second"]
✓ P2: hasConfirmedAck is per-CLIENT, not per-connection (survives reconnect) (2 ms)
An ack observed on connection #1 unlocks re-buffering on connection #2, whose endpoint never acked anything. That matters because the headline scenario here is "a backend rolling restart mid-flight" — precisely when you can be moved onto a node that behaves differently from the one that acked. In that window the duplicate-suppression gate is open on no evidence, which is the duplicate the gate exists to prevent.
Either reset it in connect() (making the comments and test names true), or keep it sticky and fix the comment + both test names so they stop asserting a guarantee the code does not provide. I'd lean toward per-connection: it is strictly safer and costs at most one unprotected message per connection.
3. 🟡 emitWithAck now governs two independent directions
The option doc is honest about the widening, but it couples two unrelated decisions onto one flag. A consumer who sets emitWithAck: false today does so to change what the backend does (it is forwarded as a connect query param); they now also silently lose the new outbound protection. Conversely there is no way to keep inbound acking while opting out of the new 10s outbound wait. A separate option (ackOutboundMessages, defaulting to emitWithAck) would keep both knobs independent and make the semver story much easier.
Smaller points
- Untested success criterion. "
switchSession()/disconnect()must not leak an old session's buffered message into a newly started session" has no test — which is how finding 1 slipped through. Worth covering both directions: leak, and non-loss of legitimately buffered messages. - At-least-once ⇒ duplicates, with no idempotency key. Endpoint processes the message, ack is lost in the drop → resend → the flow runs twice. Fine for chit-chat, not for "book the flight". The description flags the semantic, but a client-generated message id that the endpoint could dedupe on would turn at-least-once into effectively-once and de-risk the whole change.
PROCESS_INPUT_ACK_TIMEOUT_MS = 10000is hardcoded and, stylistically, astatic readonlydeclared mid-class between methods; consider making it anOptionsfield next tointerval/reconnectionLimit..npmignorestill has no trailing newline (both edited files end mid-line).
Relationship to Cognigy/Webchat#313 — these do not overlap
Both PRs are CGY-3852 but fix different failure modes, and it is worth being explicit in both descriptions:
- This PR: message lost after a successful send, in the disconnect-before-ack window.
- Webchat#313: no reconnect is ever attempted when the first connection attempt fails.
Importantly, this PR does not fix the upstream defect behind Webchat#313: setupReconnectInterval() is still only reachable from inside socket.on("connect") (line 352), so a first connect() that rejects still leaves no retry machinery at all — no interval, and the buffered message sits in messageBuffer forever. That is the one change here that would have fixed the problem in every consumer instead of just Webchat. Worth a follow-up ticket, or folding into this PR while the area is already open.
Dmitrii Ostasevich (kwinto)
left a comment
There was a problem hiding this comment.
Correction to my review above — wrong symbol cited, finding unchanged.
In finding 1 I wrote that the drop path is reachable from "Start new conversation (handleStartConversation)". That function name is wrong: WebchatUI.handleStartConversation only calls onSetShowHomeScreen(false) / onSetShowChatOptionsScreen(false) / onShowChatScreen() and never touches switchSession. Apologies — I should have checked before naming it.
The finding itself stands; here are the call sites I actually verified on Webchat@main, all of which dispatch switchSession() with no accompanying sendMessage, so anything sitting in messageBuffer is discarded:
| Call site | Trigger |
|---|---|
ConversationsList.tsx:86 (handleStartButtonClick) |
"Start new conversation" in the previous-conversations list |
DeleteConversation.tsx:48 |
Delete the current conversation |
DeleteAllConversations.tsx:30 |
Delete all conversations |
Webchat.tsx:213 (endSession) |
Public embedding API — webchat.endSession() |
The last one is worth highlighting more than the one I originally cited: endSession() is public API that integrators call from their own page code, and it is documented as "switch to a new session and clear the messages in the current session" — nothing there implies "and silently discard an undelivered message the user already saw as sent".
For completeness, the one place that is safe is WebchatUI.tsx:1116 (handleSendActionButtonMessage), because it dispatches onSwitchSession() immediately before onSendMessage(), so the clear happens first and the message is buffered afterwards. That ordering is currently load-bearing but unenforced — which is really an argument for scoping the clear to just the re-buffered in-flight message, rather than relying on dispatch order at every call site.
Dmitrii Ostasevich (kwinto)
left a comment
There was a problem hiding this comment.
Amending my own suggestion for finding 1 — and I've now validated the replacement.
My original suggestion (preserve messages buffered before the switch) was wrong: it would deliver a session-A message into session B, which directly violates your own success criterion "switchSession()/disconnect() must not leak an old session's buffered message into a newly started session". Scratch that — your drop is the right call.
The actual defect is narrower than I framed it: not that the message is dropped, but that it is dropped silently while the consumer has already rendered it as sent. Keep the drop; just make it observable.
I implemented this on your branch and verified it — tsc clean, and all 5 of your existing tests still pass (8/8 with the 3 I added):
this.disconnect();
// Any message still buffered at this point belongs to the session
// we're leaving (disconnect() may have just re-buffered an
// in-flight message via the ack-callback error path above). Drop
// it here so flushMessageBuffer() doesn't replay it into the new
// session once it connects.
+ //
+ // These messages were never delivered, and a consumer such as the
+ // Webchat has already rendered them as sent, so tell it they are
+ // gone instead of dropping them silently.
+ const undelivered = this.messageBuffer;
this.messageBuffer = [];
+
+ if (undelivered.length > 0) {
+ this.emit("messagesDiscarded", {
+ reason: "session-switched",
+ messages: undelivered,
+ });
+ }Tests I used (happy to open them as a PR against your branch if useful):
✓ still does not leak an old session's messages into the new session (32 ms)
✓ tells the consumer which messages were discarded, instead of dropping them silently (3 ms)
✓ stays quiet when there is nothing undelivered to report (1 ms)
Why this is worth the extra ~8 lines:
- It closes the third untested success criterion under the same ticket, using the mechanism the class already has (
EventEmitter), with no behaviour change for consumers that don't subscribe — so it stays additive for semver. - It gives Webchat somewhere to hang a "not delivered" affordance later, rather than baking a silent-loss path into the client that no consumer can even detect.
- It is reachable from the public
webchat.endSession()API, so integrators can hit it from their own page code with no way to know a message vanished.
Naming is a straw man — messagesDiscarded / reason: "session-switched" just mirrors the existing socket/error { type } shape; rename freely.
|
Status check: this PR is unchanged since my review (head
Also worth noting: the author of #313 filed CGY-37178 for the On the open Copilot thread about the ack callback observing a different socket instance — I looked into this, and I don't think it's reachable, so it shouldn't block: I read The same reading is also why the For completeness, I also chased and disproved a related concern: |
Success criteria
sendMessage's "are we connected" check and the endpoint actually processing the message (e.g. a backend rolling restart mid-flight), the message must be re-buffered and resent on the next successful reconnect instead of being silently lost while the UI already shows it as sent.processInputat all, no message may ever be duplicated — re-buffering only kicks in once this endpoint has been positively observed to ack a message.switchSession()/disconnect()must not leak an old session's buffered message into a newly started session.emitWithAck: falsemust keep the exact previous fire-and-forget behavior.npm testmust be reliable on a cold cache (no test framework existed before this PR — it's added from scratch).How to test
npm install && npm test— 5/5 Jest tests pass, includingsrc/socket-client.test.ts'sSocketClient#sendMessagesuite covering the ack-gating, disconnect-before-ack re-buffer, no-ack-endpoint safety, andemitWithAck: falsefallback.npm run build— clean, no TypeScript errors.npm pack --dry-run— tarball no longer contains.test.js/.test.d.tsorjest.config.js.Root cause
SocketClient.sendMessage()did a baresocket.emit("processInput", payload)with no ack, no timeout, no retry whenever it believed it was connected. If the connection dropped in the window between that check and the server actually processing the message, the message was silently lost with no recovery path — see the root-cause writeup on CGY-3852.This PR:
socket.timeout(ms).emit(event, payload, cb)) whenemitWithAck(existing default) is true.hasConfirmedAck), so a non-acking endpoint can never receive a duplicate — the very first message on a connection is not protected (same as today's status quo for that case), but every message after the first confirmed ack is.messageBufferinswitchSession()so an old session's in-flight message can't replay into a new one.setIntervaltyping collision that made a from-scratchnpm testunreliable on a cold cache.Security
Additional considerations
sendMessagecalls now wait on an ack (default 10s timeout) instead of being pure fire-and-forget; this is the intended trade-off (delivery confirmation vs. silent loss).Semver note: this changes default runtime behavior for consumers who don't override
emitWithAck(the existing default,true) — recommend at least a minor version bump with a changelog entry calling out the new default outbound ack, the at-least-once resend semantic, andemitWithAck: falseas the opt-out.🤖 Generated with a debugging/planning/multi-agent-implementation session using Claude Code.