Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions integrationTests/cluster/oversizedFrameCursorSafety.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Integration test: an oversized replication transaction must not be silently skipped.
*
* The production failure (harper-pro#711) was permanent data loss: a queued transaction larger than
* replication_maxPayload was dropped with a bare `return`, and later transactions kept advancing the
* peer's resume cursor past it. The fix throws instead, which closes the leg so it resumes from the
* un-advanced cursor. This pins the cursor-safety invariant: once a transaction is too large to send,
* NO later transaction is delivered ahead of it.
*
* Setup: a small maxPayload on the source. Write a normal record (delivered), then an oversized record
* (wedges the send leg), then another normal record. The follower must have the first record but NOT
* the third. On base (silent drop) the third arrives while the oversized one is lost, so this fails.
*
* Keys are ordered k1 < k2 (oversized) < k3 so that neither delivery path can hand out the later record
* early: a wedged leg reconnects into a key-ordered full copy, so k3 sorting AFTER the oversized k2
* (rather than alphabetically before it) is what makes its absence a real cursor-safety signal.
*/
import { suite, test, before, after } from 'node:test';
import { equal, ok } from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
import { startHarper, teardownHarper, getNextAvailableLoopbackAddress } from '@harperfast/integration-testing';
import { join } from 'node:path';
import { sendOperation } from './clusterShared.mjs';

process.env.HARPER_INTEGRATION_TEST_INSTALL_SCRIPT = join(import.meta.dirname ?? module.path, '..', '..', 'dist', 'bin', 'harper.js');

const MAX_PAYLOAD = 512 * 1024; // 512 KiB send cap
const OVERSIZED_BYTES = 1024 * 1024; // 1 MiB value -> transaction frame exceeds the cap

suite('oversized replication transaction does not skip later transactions', { timeout: 180000 }, (ctx) => {
before(async () => {
const hostnameA = await getNextAvailableLoopbackAddress();
const hostnameB = await getNextAvailableLoopbackAddress();
const makeNodeCtx = (hostname) => ({ name: ctx.name, harper: { hostname } });
const commonConfig = (hostname) => ({
config: {
analytics: { aggregatePeriod: -1 },
logging: { colors: false, stdStreams: false, console: true },
replication: { port: hostname + ':9933', securePort: null, databases: ['data'], maxPayload: MAX_PAYLOAD },
},
env: { HARPER_NO_FLUSH_ON_EXIT: true },
});
const ctxA = makeNodeCtx(hostnameA);
const ctxB = makeNodeCtx(hostnameB);
await Promise.all([startHarper(ctxA, commonConfig(hostnameA)), startHarper(ctxB, commonConfig(hostnameB))]);
ctx.nodeA = ctxA.harper;
ctx.nodeB = ctxB.harper;

await sendOperation(ctx.nodeA, { operation: 'create_table', database: 'data', table: 'oversized_test', primary_key: 'id' });
// Establish replication before any oversized write, so the base copy (small) is unaffected.
await sendOperation(ctx.nodeB, {
operation: 'add_node',
hostname: ctx.nodeA.hostname,
rejectUnauthorized: false,
isLeader: true,
authorization: ctx.nodeA.admin,
});
});

after(async () => {
await Promise.all([ctx.nodeA && teardownHarper({ harper: ctx.nodeA }), ctx.nodeB && teardownHarper({ harper: ctx.nodeB })]);
});

test('the follower receives the pre-oversized record but not the one queued behind it', async () => {
const { nodeA, nodeB } = ctx;
const hasId = async (id) =>
((await sendOperation(nodeB, {
operation: 'search_by_value',
database: 'data',
table: 'oversized_test',
search_attribute: 'id',
search_value: id,
get_attributes: ['id'],
}).catch(() => [])) ?? []).length > 0;

// A normal record, delivered normally before the wedge.
await sendOperation(nodeA, { operation: 'upsert', database: 'data', table: 'oversized_test', records: [{ id: 'k1_before', v: 'x' }] });
for (let i = 0; i < 60 && !(await hasId('k1_before')); i++) await delay(500);
ok(await hasId('k1_before'), 'the pre-oversized record must replicate normally');

// An oversized transaction: too large to send, so the leg wedges on it.
await sendOperation(nodeA, {
operation: 'upsert',
database: 'data',
table: 'oversized_test',
records: [{ id: 'k2_oversized', v: 'x'.repeat(OVERSIZED_BYTES) }],
});
// A later normal transaction: must NOT be delivered ahead of the wedged one.
await sendOperation(nodeA, { operation: 'upsert', database: 'data', table: 'oversized_test', records: [{ id: 'k3_after', v: 'x' }] });

// Give the leg ample time to (wrongly, on base) skip and deliver 'after'.
await delay(10000);
equal(await hasId('k3_after'), false, 'a transaction queued behind an oversized one must not be delivered');
equal(await hasId('k2_oversized'), false, 'the oversized transaction itself cannot be sent under this cap');
});
});
66 changes: 52 additions & 14 deletions replication/replicationConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,20 @@
}

const MAX_PAYLOAD = env.get('replication_maxPayload') ?? 100_000_000;
// Flush a bulk-copy batch once it approaches the payload cap, so a run of large rows can't assemble a
// single oversized frame (harper-pro#711) that would throw and wedge the copy with no operator escape
// (existing rows can't be split). Leaves headroom for the in-progress record; a single row above this
// still relies on raising replication_maxPayload.
const COPY_FLUSH_BYTES = Math.floor(MAX_PAYLOAD * 0.9);

// Pure oversized-frame predicate, extracted so it is unit-testable (the frameWriter/copyFlushPacer
// pattern, harper-pro#440). A frame at exactly the cap is allowed; only a strictly larger one is rejected.
export function exceedsMaxPayload(messageSize: number, maxPayload: number = MAX_PAYLOAD): boolean {
return messageSize > maxPayload;
}
// Throttle the oversized-send error: it closes and reconnects the leg, so it would otherwise re-log on
// every reconnect cycle. Module-level so the throttle survives across the per-cycle connection instances.
const oversizedSendWarnThrottle = createThrottleState();
// When receiving a replication message, we apply per-record backpressure to keep a single
// large batch from synchronously decoding thousands of records and ballooning the worker
// heap past its limit. If the local replicator queue grows beyond this threshold we pause
Expand Down Expand Up @@ -831,7 +845,7 @@
logger.warn?.(`[test] forcing open-but-idle replication wedge for db "${databaseName}" (harper-pro#420)`);
ws.terminate = () => {};
ws.close = () => {};
ws._socket?.pause();

Check failure on line 848 in replication/replicationConnection.ts

View workflow job for this annotation

GitHub Actions / Unit Tests (Node.js v24)

Property '_socket' does not exist on type 'WebSocket'.

Check failure on line 848 in replication/replicationConnection.ts

View workflow job for this annotation

GitHub Actions / Unit Tests (Node.js v22)

Property '_socket' does not exist on type 'WebSocket'.

Check failure on line 848 in replication/replicationConnection.ts

View workflow job for this annotation

GitHub Actions / Unit Tests (Node.js v26)

Property '_socket' does not exist on type 'WebSocket'.

Check failure on line 848 in replication/replicationConnection.ts

View workflow job for this annotation

GitHub Actions / Build Harper Pro (Node.js v24)

Property '_socket' does not exist on type 'WebSocket'.

Check failure on line 848 in replication/replicationConnection.ts

View workflow job for this annotation

GitHub Actions / YCSB single-node (Node.js v24)

Property '_socket' does not exist on type 'WebSocket'.
return true; // tell the watchdog to treat this connection's byte count as frozen
}

Expand Down Expand Up @@ -2056,6 +2070,10 @@
noDelay: true, // we want to send the data immediately
// we set this very high (2x times the v22 default) because it performs better
highWaterMark: 128 * 1024,
// Match the server's accept cap (replicator.ts) so the configurable send guard (replication_maxPayload)
// is the binding limit on client-initiated legs too. Otherwise ws defaults to 100 MiB here, just under
// the 100 MB send default, and raising replication_maxPayload to clear a wedge would hit a silent 1009.
maxPayload: 10 * 1024 * 1024 * 1024,
rejectUnauthorized: rejectUnauthorized !== false,
secureContext: undefined,
};
Expand Down Expand Up @@ -2189,8 +2207,9 @@
this.socket._socket.unref();
// in normal startup, just use info, but adjust log level to warn if we were previously disconnected, because there was a warn message on the disconnect and we want to keep symmetry
logger[this.isConnected ? 'info' : 'warn']?.(`Connected to ${this.url}, db: ${this.databaseName}`);
this.retries = 0;
this.retryTime = INITIAL_RETRY_TIME;
// Reset backoff on first successful SEND (onFrameSent), not here on open: a leg that reopens and
// immediately fails to send (an oversized frame throws and closes it) must keep escalating toward
// the 30 s cap instead of hot-looping at 500 ms and accumulating native TLS state (harper-pro#339).
// if we have already connected, we need to send a reconnected event
if (this.nodeSubscriptions) {
connectedToNode({
Expand Down Expand Up @@ -2331,6 +2350,14 @@
// Doubling reaches 30 s in ~6 retries (~62 s total) and resets on success.
this.retryTime = Math.min(this.retryTime << 1, 30_000);
}
// Called by replicateOverWS after a frame is actually sent: real progress, so it is safe to reset the
// backoff. Gated on a non-zero retries so the healthy hot path (already reset) does nothing.
onFrameSent() {
if (this.retries !== 0 || this.retryTime !== INITIAL_RETRY_TIME) {
this.retries = 0;
this.retryTime = INITIAL_RETRY_TIME;
}
}
// Retire the live replicateOverWS instance: the single enforcement point for "at most one live session
// per connection". Every path that supersedes a session (socket replaced in connect(), forceReconnect)
// calls this; the socket's own 'close' does the same teardown through retireInstance, which is
Expand Down Expand Up @@ -4623,8 +4650,15 @@
const sendQueuedData = () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: on the base-copy path there is no "split the transaction" escape, and a clone can never reach Available

sendQueuedData is also called from the full-copy loop at lines 4550 and 4626, where the frame is assembled from up to COPY_CHECKPOINT_RECORDS (default 1000, line 249) existing rows rather than from a client transaction. A table averaging >100 KB per record therefore produces an oversized copy batch at the default 100 MB cap — issue #711's own repro is 1200 × 100 KB. After this change that throw closes the leg, the copy resumes from the same afterKey, re-assembles the same batch, and throws again: the clone never emits COPY_COMPLETE and never reaches Available. The PR's stated remedies don't apply — an operator can't "split" rows that already exist, leaving only raising replication_maxPayload (see the note on line 4296).

Unlike the audit-log path, this one is fixable now without the deferred chunking protocol change: both copy flushes are documented in-code as plain flushes with no sequence update, so splitting them is safe.

Suggested fix: add a byte-based condition to the copy checkpoint at 4621 alongside the record count — flush when frame.position - frame.encodingStart approaches MAX_PAYLOAD — so the copy path never builds an oversized frame in the first place. That leaves only the genuinely atomic single-transaction case for the chunking follow-up.

—
Generated by Barber AI

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed for the batch case in 367988f: added a byte threshold (COPY_FLUSH_BYTES = floor(MAX_PAYLOAD * 0.9)) alongside the record count at the copy checkpoint, so a run of large rows flushes before assembling an oversized frame. As you noted, both copy flushes are plain (no sequence update), so splitting is safe.

Caveat kept honest: a SINGLE row larger than the cap still cannot be split and relies on raising replication_maxPayload — the #711 repro (1200 x 100 KB) is the batch case and is now fixed; one >100 MB row is not. Rig-confirmed a single oversized row still wedges the copy (expected), while normal large-row batches flush under the cap.

Lavinia, via Claude

if (frame.position - frame.encodingStart > 8) {
// if we have more than just a txn time, send it
if (checkExcessMessageSize(frame.position - frame.encodingStart)) return;
// Throw, don't return: a silent return skips this transaction while later ones
Comment thread
cb1kenobi marked this conversation as resolved.
// keep advancing the peer's resume cursor past it, losing it permanently. Throwing
// closes the leg so it resumes from the un-advanced cursor (as the blob path does).
if (checkExcessMessageSize(frame.position - frame.encodingStart))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a regression test for this cursor-safety path? Configure a small replication payload cap, send an oversized transaction followed by another transaction, then verify that reconnect/replay delivers both rather than advancing past the rejected one. The production failure here was permanent data loss, and the current change has no coverage of the throw → close → resume chain.

— KrAIs (Codex)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 367988f: integrationTests/cluster/oversizedFrameCursorSafety.test.mjs. Small maxPayload (512 KiB); write a normal record, then an oversized one (1 MiB value), then another normal record whose key sorts AFTER the oversized one. Asserts the follower has the first but not the oversized or the third.

Fails on base (the silent return skips the oversized txn, the copy completes, and the third arrives). Rig-verified against the built change: before=true, oversized=false, after=false with the fix; after=true on base (the silent skip). Key ordering matters here because a wedged leg reconnects into a key-ordered full copy, so the third record has to sort after the oversized one for its absence to mean something.

Lavinia, via Claude

Comment thread
cb1kenobi marked this conversation as resolved.
throw new Error('Replication message too large to send');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: this close reconnects every ~500 ms forever — the backoff never engages

The throw reaches the .catch at line 4709 → close(1008, …) → the close handler at 2058 → scheduleReconnect(). That handler's escalating backoff (500 ms → 30 s) is reset to INITIAL_RETRY_TIME by socket.on('open') at lines 1992-1993, and the oversized-frame close always happens after the socket opened. So every cycle reconnects at the base 500 ms: a full mTLS handshake, an audit-log rescan from the un-advanced cursor, and a re-encode of the same ≥100 MB frame, indefinitely. The same reset (this.retries = 0) also defeats the ++this.retries % 20 === 1 throttle at line 2100, so that warning plus checkExcessMessageSize's logger.error plus the .catch's logger.error all fire on every cycle — roughly 6 lines/sec per wedged (peer, database) leg. The scheduleReconnect comment at 2126-2132 documents this exact hazard: rapid reconnects accumulating unreleased native TLS state faster than V8 can GC, leading to OOM (#339). This change turns that into a permanent generator.

The PR body's "the leg cycles with a logged error" understates this; the trade-off being accepted is a hot loop, not a quiet hold.

Suggested fix: gate the backoff reset on progress rather than on open — only reset retries/retryTime once a frame has actually been sent — so a leg that reopens and immediately fails escalates to the 30 s cap. Throttling checkExcessMessageSize's error log would also help (blobSendWarnThrottle is existing precedent).

—
Generated by Barber AI

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 367988f. Moved the backoff reset off socket open and onto genuine progress: a new onFrameSent() on NodeReplicationConnection, called from sendQueuedData only after a frame is actually sent (options.connection?.onFrameSent?.()). A leg that reopens and immediately throws on an oversized frame never sends, so retries/retryTime keep escalating to the 30 s cap.

Rig-verified: the wedged leg's reconnects widened (~500 ms → ~8 s → ~16 s) instead of hot-looping at 500 ms. Also throttled checkExcessMessageSize's error through the existing decideThrottledWarn/blobSendWarnThrottle precedent (module-level so it survives the per-cycle connection instances); the rig showed the (3 suppressed) fold.

Lavinia, via Claude

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: the invariant has a bypass — skipAuditRecord's 300 ms timer advances the peer's cursor with a direct ws.send, outside this guard

The throw here correctly keeps the cursor pinned on this path, because the REMOTE_SEQUENCE_UPDATE rides inside the frame — no send, no advance. But it is not the only thing that moves the peer's resume cursor.

skipAuditRecord (4509-4523, unchanged by this PR and outside its diff) arms a one-shot 300 ms timer that does:

ws.send(encode([SEQUENCE_ID_UPDATE, currentSequenceId]));

That is a direct ws.send, so it never passes through sendQueuedData or checkExcessMessageSize. On the receiving side (3898-3919) SEQUENCE_ID_UPDATE persists the resume cursor via tableSubscriptionToReplicator.send(seqUpdateEndTxn(...)).

currentSequenceId = key is assigned at 5051 before await sendAuditRecord(auditRecord, key), so it already names the record being accumulated into the not-yet-sent frame. Sequence:

  1. A skipped record — excluded table (4396), LOCAL_ONLY (4375), unsubscribed table (4392), or 4445/4475 — arms the timer.
  2. The loop reaches the large transaction at audit time T; records accumulate for >300 ms with no flush (the frame only flushes at end_txn 4366 or a txnTime change 4577).
  3. The timer fires. sentSequenceId still holds the last flushed end_txn, so the 4515 guard (sentSequenceId || 0) + 150 < currentSequenceId passes, and SEQUENCE_ID_UPDATE(T) goes out on a still-open socket.
  4. The follower persists its cursor at T.
  5. The big transaction's end_txn reaches this throw and closes the leg.
  6. On reconnect the replay uses start: currentSequenceId || 1, exclusiveStart: true (5031-5032), so records at localTime <= T are skipped — the oversized transaction is gone.

Worth being clear about scope: this path is pre-existing (blame a4f0ba85, ancestor of the merge base), it is strictly worse under the old bare return, and it is not a regression from this PR. But it does mean the stated invariant — "an unsent transaction is never followed by a resume-cursor advance" — is not fully established at this head. The copy path is immune (currentSequenceId === 0 during the walk), so this is the audit-replay path only.

Confidence: I traced every line involved, but did not reproduce the race — it needs a skip to arm the timer plus >300 ms of accumulation. Your new integration test cannot catch it as written: with databases: ['data'] and no exclusions there are no skipped records, so the timer is never armed.

Suggested fix: suppress the skip update while a frame is pending — if (frame.position > frame.encodingStart) return; inside the timer — so a cursor advance can never overtake un-flushed data, and add a closed || wsClosed bail while you are there.

—
Generated by Barber AI

ws.send(frame.encodingBuffer.subarray(frame.encodingStart, frame.position));
// A frame actually went out: tell the outbound connection so it can reset its reconnect
// backoff on genuine progress rather than on bare socket-open (harper-pro#339).
options.connection?.onFrameSent?.();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: onFrameSent() is never called — the backoff reset is dead code, and every healthy leg now degrades to 30 s reconnects

I verified this on a live two-node rig rather than by reading: I instrumented this exact call site in the built dist/replication/replicationConnection.js (the artifact the harness actually loads) to log whether options.connection is defined, and ran a bidirectional replication test with writes on both nodes. Result: 13 executions of this line, all with options.connection === undefined, and onFrameSent() invoked zero times.

The reason is structural. sendQueuedData is defined inside case SUBSCRIPTION_REQUEST: (line 4207), so it only exists on the leg that receives a subscription request — an accepted server-side socket. options.connection is assigned at exactly one place repo-wide (line 2229, connection: this), on the outbound NodeReplicationConnection. The two replicator.ts callers (176, 617) pass no connection. So the sending leg never has a connection, and the double optional chaining silently swallows the call.

The consequence is larger than the oversized-frame case, because this PR also removed the open-handler reset at 2208-2209. With onFrameSent() inert, retries and retryTime are now never reset for the life of a NodeReplicationConnection:

  • retryTime escalates 500 ms → 30 s in ~7 lifetime disconnects and then stays pinned at 30 s forever, on every leg, even against a perfectly healthy peer. Reconnect latency degrades cluster-wide and never recovers.
  • retries becomes "total disconnects ever" rather than "since last connect", so the throttle at 2319 (++this.retries % 20 === 1) logs only 1 disconnect in 20. That breaks the pairing the open handler at 2208 documents ("there was a warn message on the disconnect and we want to keep symmetry") — you get "Connected to X" warns with no matching "Disconnected from X" 19 times out of 20.

The hot-loop from my earlier comment is genuinely gone, but by deletion rather than by this mechanism — so the fix reads as targeted while actually being unconditional.

Suggested fix: drive the reset from a signal the leg holding the backoff can observe. The subscriber side is the one with the NodeReplicationConnection, so resetting on the first received application frame (or first cursor advance) on that socket restores fast reconnects for healthy legs while still leaving a leg that reopens and immediately throws with nothing to reset it. Alternatively reinstate the open reset but arm it behind a stability window (reset only if the socket has stayed open for, say, 10 s), which separates "reopened and immediately failed" from "connected fine" without depending on send traffic.

—
Generated by Barber AI

logger.debug?.(connectionId, 'Sent message, size:', frame.position - frame.encodingStart);
if (databaseName !== 'system') {
recordAction(
Expand Down Expand Up @@ -4948,7 +4982,8 @@
// naturally above; this also bounds same-version bulk data into committable batches.) The
// watermark is only advanced to copyStartTime by the single end_txn after the whole copy.
if (
++recordsSinceCheckpoint >= COPY_CHECKPOINT_RECORDS &&
(++recordsSinceCheckpoint >= COPY_CHECKPOINT_RECORDS ||
frame.position - frame.encodingStart >= COPY_FLUSH_BYTES) &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: the byte checkpoint is evaluated after the record is appended, so a run of large rows still assembles an oversized frame

This is the right idea and it does fix the harper-pro#711 repro (1200 x 100 KB), but the threshold is checked once the record is already in the frame. sendAuditRecord above appends, then this condition flushes only when the accumulated size has already reached COPY_FLUSH_BYTES. So the frame can legitimately reach 0.9 * MAX_PAYLOAD + recordSize before anything flushes.

Concretely at the 100 MB default: rows of ~60 MB each accumulate to 60 MB (below the 90 MB threshold, no flush), then the next append takes the frame to 120 MB — which exceeds MAX_PAYLOAD and throws, wedging the copy exactly as before. The guard only holds when every row is under 0.1 * MAX_PAYLOAD.

That makes the PR body's caveat narrower than the actual behavior: it says a single row above the cap still needs replication_maxPayload raised, but the real residue is a run of rows each above ~10% of the cap. Same-version bulk-loaded data is the case that reaches here, since differing versions already flush at the txnTime change on 4577.

Suggested fix: flush before appending when the incoming record would not fit — i.e. check frame.position - frame.encodingStart + recordSize > MAX_PAYLOAD ahead of the append and flush the accumulated batch first. That reduces the failure set to genuinely single rows larger than the cap, which is the caveat the PR body already documents.

—
Generated by Barber AI

frame.position - frame.encodingStart > 8
) {
recordsSinceCheckpoint = 0;
Expand Down Expand Up @@ -6654,16 +6689,19 @@
};

function checkExcessMessageSize(messageSize) {
if (messageSize > MAX_PAYLOAD) {
logger.error?.(
connectionId,
'Message too large to send, size:',
messageSize,
'remote node:',
remoteNodeName,
'database:',
databaseName
);
if (exceedsMaxPayload(messageSize)) {
const { emit, suppressedCount } = decideThrottledWarn(oversizedSendWarnThrottle, Date.now());
if (emit)
logger.error?.(
connectionId,
'Message too large to send, size:',
messageSize,
'remote node:',
remoteNodeName,
'database:',
databaseName,
suppressedCount ? `(${suppressedCount} suppressed)` : ''
);
return true;
}
}
Expand Down
30 changes: 30 additions & 0 deletions unitTests/replication/exceedsMaxPayload.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Coverage for `exceedsMaxPayload` — the pure oversized-frame predicate extracted from the connection
* closure (harper-pro#711 review) so the send-or-throw decision can be pinned without a live socket. The
* boundary is inclusive: a frame at exactly the cap is allowed; only a strictly larger one is rejected,
* matching the ws server's own accept semantics.
*/
import { expect } from 'chai';
import { exceedsMaxPayload } from '#src/replication/replicationConnection';

describe('exceedsMaxPayload', () => {
const cap = 100_000_000;

it('allows a frame below the cap', () => {
expect(exceedsMaxPayload(0, cap)).to.equal(false);
expect(exceedsMaxPayload(cap - 1, cap)).to.equal(false);
});

it('allows a frame exactly at the cap (boundary is inclusive)', () => {
expect(exceedsMaxPayload(cap, cap)).to.equal(false);
});

it('rejects a frame strictly above the cap', () => {
expect(exceedsMaxPayload(cap + 1, cap)).to.equal(true);
});

it('honors an explicit smaller cap', () => {
expect(exceedsMaxPayload(2_000_000, 1_000_000)).to.equal(true);
expect(exceedsMaxPayload(500_000, 1_000_000)).to.equal(false);
});
});
Loading