test(#838): define broker reply framing behavior
This commit is contained in:
20
docs/scratchpads/838-broker-acceptance-flake.md
Normal file
20
docs/scratchpads/838-broker-acceptance-flake.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Issue #838 — Broker acceptance socket flake
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Eliminate high-contention uncaught JSON parse failures in lease-broker and mutator-gate acceptance helpers without laundering malformed/empty replies into passing assertions.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Branch: `fix/838-broker-acceptance-flake` in `/home/hermes/agent-work/stack-838-flakefix`.
|
||||||
|
- Red-first TDD with a forced empty/truncated reply path and deterministic rejection or documented retry.
|
||||||
|
- Read newline-framed replies completely; reject malformed broker replies with byte length/content context.
|
||||||
|
- Determine RIDER2 branch before finalizing: test-harness-only `(a)` or daemon write truncation `(b)`.
|
||||||
|
- If product-side, prove the real Claude/Pi adapter read path fails closed; fix any allow-risk.
|
||||||
|
- Coverage >=85% per changed executable through real tests.
|
||||||
|
- Full suite and repository gates green; independent exact-head review required.
|
||||||
|
- Push and open a PR containing `closes #838`; do not merge.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- 2026-07-17: Reclaimed from #824. Confirmed clean worktree on `fix/838-broker-acceptance-flake` at main base `abd2791f59b3f06f46dd08e55298ced72f6aa7c2`.
|
||||||
79
packages/mosaic/src/lease-broker/broker-test-client.spec.ts
Normal file
79
packages/mosaic/src/lease-broker/broker-test-client.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { createServer, type Server } from 'node:net';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { requestBrokerReply } from './broker-test-client.js';
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
const servers: Server[] = [];
|
||||||
|
|
||||||
|
async function scriptedBroker(
|
||||||
|
replies: ReadonlyArray<ReadonlyArray<Buffer>>,
|
||||||
|
): Promise<{ socketPath: string; connections: () => number }> {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'mosaic-broker-client-'));
|
||||||
|
roots.push(root);
|
||||||
|
const socketPath = join(root, 'broker.sock');
|
||||||
|
let connections = 0;
|
||||||
|
const server = createServer((socket) => {
|
||||||
|
const chunks = replies[connections] ?? replies.at(-1) ?? [];
|
||||||
|
connections += 1;
|
||||||
|
socket.once('end', () => {
|
||||||
|
for (const chunk of chunks) socket.write(chunk);
|
||||||
|
socket.end();
|
||||||
|
});
|
||||||
|
socket.resume();
|
||||||
|
});
|
||||||
|
servers.push(server);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(socketPath, resolve);
|
||||||
|
});
|
||||||
|
return { socketPath, connections: () => connections };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
servers
|
||||||
|
.splice(0)
|
||||||
|
.map(
|
||||||
|
(server) =>
|
||||||
|
new Promise<void>((resolve, reject) =>
|
||||||
|
server.close((error) => (error ? reject(error) : resolve())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('newline-framed broker test client', () => {
|
||||||
|
test('retries an empty early close and parses the complete newline-terminated retry', async () => {
|
||||||
|
const broker = await scriptedBroker([[], [Buffer.from('{"ok":true'), Buffer.from('}\n')]]);
|
||||||
|
|
||||||
|
await expect(requestBrokerReply(broker.socketPath, { action: 'probe' })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
expect(broker.connections()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects repeated truncated early closes with response bytes and length', async () => {
|
||||||
|
const truncated = Buffer.from('{"ok":');
|
||||||
|
const broker = await scriptedBroker([[truncated], [truncated]]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestBrokerReply(broker.socketPath, { action: 'probe' }, { earlyCloseRetries: 1 }),
|
||||||
|
).rejects.toThrow(/closed before newline.*attempts=2.*length=6.*bytes="\{\\"ok\\":"]/i);
|
||||||
|
expect(broker.connections()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a newline-terminated malformed broker reply without retrying', async () => {
|
||||||
|
const broker = await scriptedBroker([[Buffer.from('{bad}\n')]]);
|
||||||
|
|
||||||
|
await expect(requestBrokerReply(broker.socketPath, { action: 'probe' })).rejects.toThrow(
|
||||||
|
/malformed broker reply.*length=6.*bytes="\{bad\}\\n"/i,
|
||||||
|
);
|
||||||
|
expect(broker.connections()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user