test(#838): reject retry masking and dropped replies
This commit is contained in:
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||||
"test:framework-shell": "python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh"
|
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -53,29 +53,39 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('newline-framed broker test client', () => {
|
describe('newline-framed broker test client', () => {
|
||||||
test('retries an empty early close and parses the complete newline-terminated retry', async () => {
|
test('rejects an empty early close without retrying into a false green', async () => {
|
||||||
const broker = await scriptedBroker([[], [Buffer.from('{"ok":true'), Buffer.from('}\n')]]);
|
const broker = await scriptedBroker([[], [Buffer.from('{"ok":true}\n')]]);
|
||||||
|
|
||||||
await expect(requestBrokerReply(broker.socketPath, { action: 'probe' })).resolves.toEqual({
|
const failure = await requestBrokerReply(broker.socketPath, { action: 'probe' }).catch(
|
||||||
ok: true,
|
(error: unknown) => error,
|
||||||
});
|
);
|
||||||
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]]);
|
|
||||||
|
|
||||||
const failure = await requestBrokerReply(
|
|
||||||
broker.socketPath,
|
|
||||||
{ action: 'probe' },
|
|
||||||
{ earlyCloseRetries: 1 },
|
|
||||||
).catch((error: unknown) => error);
|
|
||||||
|
|
||||||
expect(failure).toBeInstanceOf(BrokerTransportError);
|
expect(failure).toBeInstanceOf(BrokerTransportError);
|
||||||
expect(failure).toMatchObject({
|
expect(failure).toMatchObject({
|
||||||
kind: 'early-close',
|
kind: 'early-close',
|
||||||
attempts: 2,
|
attempts: 1,
|
||||||
|
responseLength: 0,
|
||||||
|
responseHex: '',
|
||||||
|
});
|
||||||
|
expect(failure).toHaveProperty(
|
||||||
|
'message',
|
||||||
|
expect.stringMatching(/closed before newline.*length=0.*hex=<empty>/i),
|
||||||
|
);
|
||||||
|
expect(broker.connections()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects repeated truncated early closes with response bytes and length', async () => {
|
||||||
|
const truncated = Buffer.from('{"ok":');
|
||||||
|
const broker = await scriptedBroker([[truncated], [Buffer.from('{"ok":true}\n')]]);
|
||||||
|
|
||||||
|
const failure = await requestBrokerReply(broker.socketPath, { action: 'probe' }).catch(
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(failure).toBeInstanceOf(BrokerTransportError);
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
kind: 'early-close',
|
||||||
|
attempts: 1,
|
||||||
responseLength: 6,
|
responseLength: 6,
|
||||||
responseHex: '7b226f6b223a',
|
responseHex: '7b226f6b223a',
|
||||||
});
|
});
|
||||||
@@ -83,7 +93,7 @@ describe('newline-framed broker test client', () => {
|
|||||||
'message',
|
'message',
|
||||||
expect.stringMatching(/closed before newline.*length=6.*bytes=.*ok/i),
|
expect.stringMatching(/closed before newline.*length=6.*bytes=.*ok/i),
|
||||||
);
|
);
|
||||||
expect(broker.connections()).toBe(2);
|
expect(broker.connections()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects a newline-terminated malformed broker reply without retrying', async () => {
|
test('rejects a newline-terminated malformed broker reply without retrying', async () => {
|
||||||
|
|||||||
72
packages/mosaic/src/lease-broker/daemon_deadline_unittest.py
Normal file
72
packages/mosaic/src/lease-broker/daemon_deadline_unittest.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression tests for bounded lease-broker read/handle/send deadlines."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DAEMON_PATH = Path(__file__).parents[2] / "framework/tools/lease-broker/daemon.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("lease_broker_deadline_daemon", DAEMON_PATH)
|
||||||
|
if SPEC is None or SPEC.loader is None:
|
||||||
|
raise RuntimeError("unable to load lease broker daemon")
|
||||||
|
DAEMON = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(DAEMON)
|
||||||
|
|
||||||
|
|
||||||
|
def original_connection_budget() -> float:
|
||||||
|
read_budget = getattr(DAEMON, "READ_DEADLINE_SECONDS", None)
|
||||||
|
if isinstance(read_budget, (int, float)):
|
||||||
|
return float(read_budget)
|
||||||
|
return float(DAEMON.CONNECTION_DEADLINE_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
class SlowBroker:
|
||||||
|
def __init__(self, delay: float) -> None:
|
||||||
|
self.delay = delay
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def handle(self, _peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
|
||||||
|
self.calls += 1
|
||||||
|
time.sleep(self.delay)
|
||||||
|
return {"ok": True, "echo": request.get("action")}
|
||||||
|
|
||||||
|
|
||||||
|
class BrokerDeadlineTest(unittest.TestCase):
|
||||||
|
def test_completed_slow_handle_gets_a_complete_framed_reply(self) -> None:
|
||||||
|
broker = SlowBroker(original_connection_budget() + 0.1)
|
||||||
|
broker_lock = threading.Lock()
|
||||||
|
server, client = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
client.settimeout(original_connection_budget() + 2.0)
|
||||||
|
worker = threading.Thread(
|
||||||
|
target=DAEMON.handle_connection,
|
||||||
|
args=(server, broker, broker_lock),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
worker.start()
|
||||||
|
client.sendall(b'{"action":"probe"}\n')
|
||||||
|
client.shutdown(socket.SHUT_WR)
|
||||||
|
|
||||||
|
reply = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = client.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
reply.extend(chunk)
|
||||||
|
worker.join(timeout=2.0)
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
self.assertFalse(worker.is_alive())
|
||||||
|
self.assertEqual(broker.calls, 1)
|
||||||
|
self.assertTrue(reply.endswith(b"\n"), f"unframed reply: {bytes(reply)!r}")
|
||||||
|
self.assertEqual(json.loads(reply), {"ok": True, "echo": "probe"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user