From 0148c2c6ebd33a3bd0ce9ccdc4f173f930493cff Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Fri, 21 Aug 2026 11:48:21 -0700 Subject: [PATCH] test(server): cover buffered duplex chunk drain after a late listener bind (#11865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip uses duplex routes to carry data from plugin workers. > - PR #11860 added the product fix for buffered data after an early route end. > - The fix needs a regression test for a listener that binds after the byte cap ends the route. > - This pull request adds that test and protects the fix from later regressions. > - The benefit is clear test coverage for late-listener delivery. ## Linked Issues or Issue Description This pull request adds regression coverage for the fix in [PR #11860](https://github.com/paperclipai/paperclip/pull/11860). The product fix already exists on `master`. Before that fix, a late listener could receive no data after the byte cap ended the route. The test sends two three-byte `€` chunks to a route with a four-byte cap, waits for route end, then binds the listener. It expects the first valid chunk. ## What Changed - Add one server regression test for late-listener delivery after byte-cap route termination. - Keep the product code unchanged in this pull request. ## Verification - The test passes on the current branch. - PR #11860 merged the product fix into `master` at commit `33eb68b3ae4ce7ee27b31c59bd41db600ad47d19`. - GitHub CI passes on the current head. - Greptile reports 5/5 with no blocking finding. ## Risks Low risk. This pull request changes one test file and no product code, schema, public API, or authentication flow. ## Model Used OpenAI GPT-5. Runtime model ID: GPT-5. Context window: not exposed in this run. Capabilities used: repository review, GitHub operations, and tool use. The implementation came from the engineer's authorized test commit. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used with version and capability details - [x] I have checked ROADMAP.md and confirmed this pull request does not duplicate planned core work - [x] I have searched GitHub for duplicate or related pull requests and linked them above - [x] I have either linked an existing issue or described the issue in this pull request - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [x] I have run the relevant test and GitHub CI passes - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation, or documentation does not apply - [x] I have considered and documented the risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .../plugin-worker-manager-duplex.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/server/src/__tests__/plugin-worker-manager-duplex.test.ts b/server/src/__tests__/plugin-worker-manager-duplex.test.ts index 947315de5a..94ecc850cd 100644 --- a/server/src/__tests__/plugin-worker-manager-duplex.test.ts +++ b/server/src/__tests__/plugin-worker-manager-duplex.test.ts @@ -397,6 +397,34 @@ describe("plugin worker manager duplex channel route", () => { } }); + it("drains a buffered valid chunk to a listener that binds after the byte cap ends the route", async () => { + const handle = makeDuplexHandle({ + duplexChannelLimits: { maxTotalDataBytes: 4 }, + }); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ + workerSessionId: "ws-A", + // "€" is three bytes in UTF-8. The first chunk is 3 bytes (≤ 4), so the + // host counts and buffers it. The second chunk brings the total to 6 + // bytes (> 4), so the host ends the route. + data: [{ chunk: "€" }, { chunk: "€" }], + }), + ); + // Wait so both data frames arrive and the route ends on the byte cap before + // a listener binds. The first chunk is a valid buffered frame. The host must + // keep it, so the late listener drains it. This proves the host does not + // drop a buffered valid chunk when the route ends before a listener binds. + await expect(session.wait()).resolves.toEqual({ exitCode: null }); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + expect(chunks).toEqual(["€"]); + } finally { + await handle.stop().catch(() => undefined); + } + }); + it("ends the active route when the lifetime timer expires", async () => { const handle = makeDuplexHandle({ duplexChannelLimits: { maxDurationMs: 100 },