test(server): cover buffered duplex chunk drain after a late listener bind (#11865)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-08-21 11:48:21 -07:00 committed by GitHub
parent 5bc6031f79
commit 0148c2c6eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 28 additions and 0 deletions

View File

@ -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 },