Skip to content

fix(mllp): bound one frame's life and one peer's connection count (BACKLOG #1725) - #1309

Open
wshallwshall wants to merge 8 commits into
mainfrom
worktree-agent-a8b4e0598d34e4a04
Open

wshallwshall wants to merge 8 commits into
mainfrom
worktree-agent-a8b4e0598d34e4a04

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Bounds the life of one MLLP frame and the number of concurrent connections from one peer.

Scope: the row's items 1, 2 and 4. Item 3 -- the per-listener in-flight ingress semaphore -- is written as "Consider" in the row and is deliberately out of scope here.

The defect

messagefoundry/transports/mllp.py, _on_client, applied receive_timeout per read, so the timer reset on every byte received. A peer trickling one byte at a time held a frame open indefinitely while never being idle.

A search of messagefoundry/ for max_connections_per_host, per_host, frame_deadline and max_frame_seconds returned zero hits on every needle. Neither a per-connection frame deadline nor a per-host slot cap existed.

What ships

max_frame_seconds (default 60.0) bounds one frame start-byte to end-byte. max_connections_per_host (default 32) bounds concurrent sockets from one peer address. Both reach the connector through the MLLP() factory, which is the schema connections.toml decodes against, so there is no second configuration path.

receive_timeout is unchanged and still bounds an idle socket. The two bounds are independent and both are needed; the deadline sits beside the timeout rather than replacing it.

The default of 32, and its limits

An eighth of the existing 256 socket cap. A per-host term set near the global one bounds nothing; at an eighth it takes at least eight distinct addresses to fill a listener, while staying far above a real partner's need -- MLLP is request/response over one socket per sending channel.

Two limits are recorded at the constant rather than left implied:

  • It keys on source address, so eight addresses restore the full 256. It raises the floor on a single-address peer and does not bound a distributed one.
  • Behind a source-NAT proxy every partner shares one address, making 32 the effective listener capacity. Documented, with None or 0 as the setting for that topology, and the refusal names itself in both the log and the connection event so the diagnosis is a log line rather than an investigation.

The idle bound still fires independently, and it was proved by mutation

test_an_idle_peer_still_hits_receive_timeout_while_a_frame_is_open sets receive_timeout=0.1 against max_frame_seconds=5.0, opens a frame then goes silent, and asserts the close reason is idle_timeout. The pre-existing test_idle_timeout_close_reason covers the same bound with no frame open.

Confirmed in both directions: forcing the deadline always-spent reds that arm plus the slow-sender arm, while disabling the deadline reds only the trickle arm. Disjoint reds.

Review found a critical bug in the change itself

Round one, at xhigh, reproduced a real defect in the new code: the frame clock was stamped per connection rather than per frame, so a pipelined sender was reset every 60 seconds. Fixed, and pinned by an arm that holds the decoder continuously in-frame deterministically.

The builder's own first version of that test passed against the defect, and it was caught only by running the mutation. That is worth a reviewer's attention: the guard was written before it could fail.

Also fixed in round one: paced time being charged to the frame budget, which broke _MessagePacer's "never drops" promise; a cancellation window that leaked a per-host slot permanently; and per-refusal log amplification.

Round two found a negative-value hole (-1 is truthy, so it refused every peer while growing an attacker-keyed set), a boundary case mislabelling frame_deadline as idle_timeout, and stale state surviving stop(). All applied.

Three findings were declined with reasons in the commit message, chiefly that messagefoundry/transports/tcp.py carries the same defects and is outside this row. That is a real finding about tcp.py and it should be filed rather than lost here.

Why the diff reaches eight files

File Why
tests/test_connection_event_emit.py Where the MLLP listener's socket-level tests live, despite the narrow name. Both new bounds also surface through connection events: the per-host refusal as at_capacity with a max_connections_per_host reason, the deadline as closed with reason frame_deadline.
docs/CONNECTIONS.md The operator settings reference. A shipped-on cap absent from it is one nobody can find or tune.
tests/test_communications_inventory.py Pins each documented default to its module constant, as max_connections and receive_timeout already were.
docs/PHI.md, docs/SECURITY.md, tests/test_phi_logging_inventory.py Each carried a claim this change made false. PHI.md enumerated the closed reasons as "no path produces any other"; SECURITY.md said no per-peer bound exists and listed the caps that ship on.

The last row is the one to check. Those are security records, and a reviewer should confirm the rewording is accurate rather than merely consistent.

One judgement made without asking

The per-host refusal reuses the existing at_capacity event kind with a distinguishing reason, rather than adding a new kind. A new kind would have forced an edit to the web console's filter tuple, which CI asserts equals the engine's emit sites -- widening the diff into a package another builder might be holding, for information the reason field already carries.

Severity

No live exposure. MessageFoundry has zero deployments. Conditional only: a deploying site would have a slow-loris peer hold listener slots open indefinitely, and would have no per-peer bound at all since the source allowlist ships off and max_connections counts sockets rather than hosts.

Checks

Ran: ruff check ., ruff format --check ., mypy messagefoundry strict, all clean. Pytest over every module importing transports.mllp, found by grep rather than guessed: 976 passed, 2 skipped. Plus the config, schema and doc-drift guards: 627 passed. The new test module was run three times to check for flakes.

Not run, and these need reading on this pull request: the full pytest suite and every hosted-runner-only leg, by name windows-service-smoke, the SQL Server and Postgres store suites, and the load legs.

Ledger

docs/BACKLOG.md is untouched. It is a stub in this repository; the ledger lives in the private repository. The claim on 1725 is still held by the build worktree and is released at landing, per step 14.

This branch was interrupted three times by an antivirus process kill and a session archive, and was resumed from its own transcript each time. That cost wall-clock only; every commit here was re-verified against the tree afterwards.

wshallwshall added 5 commits September 19, 2026 13:28
… #1725)

Work in progress, committed early so an interruption cannot lose it. Checks and
review still to run; this message is amended before the push.

`receive_timeout` is applied per read, so it resets on every byte received: it
bounds a silent socket and says nothing about how long one frame may take to
arrive. `max_connections` counts sockets rather than hosts, and
`source_ip_allowlist` ships off, so a default listener carried no peer-scoped
term at all.

Items 1, 2 and 4 of the row. Item 3 (a per-listener in-flight ingress semaphore)
is written as "Consider" and is deliberately left out of scope.
…tants

`docs/CONNECTIONS.md` is the operator reference for connector settings, so a
shipped-on cap that is not in it is a cap nobody can find or tune. Three places:
the MLLP settings table gets a row each for `max_connections_per_host` and
`max_frame_seconds`; the resource-management prose gains two bullets saying what
`receive_timeout` does NOT bound and that `max_connections` counts sockets rather
than hosts; Table A and Table B's MLLP listener rows name the new bounds beside
the old ones.

Both new bullets say MLLP-listener-only in words. The raw-TCP, X12, HTTP and
DICOM intakes carry neither bound, and the tables sit close enough together that
a reader could otherwise take these as general listener behaviour.

`tests/test_communications_inventory.py` pins each stated default to the module
constant it came from (`_import_code_defaults`), the same way the existing
`max_connections` and `receive_timeout` rows are pinned. Changing a constant
without the doc now reds instead of shipping a listener whose documented default
is not the one it runs.

Items 1, 2 and 4 of BACKLOG #1725. Item 3 (a per-listener in-flight ingress
semaphore) is written as "Consider" and is deliberately left out of scope.
Review round 1, and the finding was reproduced rather than argued. A pipelined
sender's reads almost never end on a frame boundary, so `decoder.in_frame` stays
True read after read across DIFFERENT frames. Stamping only when nothing was
being timed therefore measured every later frame from the FIRST frame's start
byte: a healthy high-volume feed was reset once per `max_frame_seconds`, forever.
At the shipped 60 s default that is roughly once a minute on any feed that keeps
the socket continuously in-frame.

`decoded` is what makes the stamp per frame. Any frame that was being timed is
finished once a read completes one, so an open frame after that is a new one and
its clock starts there.

The existing control arm could not see this: it sends ONE frame, so `in_frame`
goes False and the clock never runs across frames. The new arm keeps the decoder
continuously in-frame by ending every write with the next frame's start byte,
which is deterministic rather than dependent on where a 4096-byte read happens to
land. A first version of it wrote all the frames at once and passed against the
defect, because two reads drained the lot.

Two further fixes in the same layer:

- Paced time is no longer charged to the frame's budget. `_MessagePacer` promises
  it "never drops, never NAKs and never refuses"; without this the engine's own
  back-pressure could close a connection holding a partial frame, which is that
  promise broken and a partial frame discarded outside the count-and-log boundary.
- `_admit()`'s slot is now released on a cancellation during the `established`
  emit. That emit is fail-soft against `Exception` but not `CancelledError`, and
  `stop()` cancels straggling client tasks. It leaked an `_active` count before;
  with a per-host table it would leak an entry nothing decrements, locking that
  address out until restart.

The trickle test now accepts an abortive RST as well as a clean EOF. Windows
sends RST when a socket is closed with bytes still unread, and a peer that is
still writing when the listener closes sits in exactly that window; asserting
`b""` alone flaked on which the OS chose.

Items 1, 2 and 4 of BACKLOG #1725. Item 3 (a per-listener in-flight ingress
semaphore) is written as "Consider" and is deliberately left out of scope.
…three claims

The rest of review round 1. The critical per-frame clock fix landed separately in
the commit before this one.

Behaviour:

- The per-host refusal now logs once per host per episode instead of once per
  attempt. A peer looping connect() against a budget it has already filled is
  refused as fast as it can open sockets, and NSSM captures the service's stdout
  to files, so a line per refusal let an unauthenticated peer fill the log volume
  -- the defense amplifying the flood it exists to stop. The global
  `max_connections` refusal logs nothing at all; one line per episode is the
  middle ground, and the `at_capacity` event still records every refusal. The
  warned set is keyed and cleared exactly like `_per_host`, so it adds no second
  population to leak, and it clears at zero rather than at the cap so a peer
  oscillating on the boundary cannot earn a line per cycle.
- `_read_budget` drops an unreachable `max(0.0, ...)` clamp. No input could reach
  it: `receive_timeout` is None rather than falsy when off, and the caller has
  already broken out of the loop on a spent frame budget. Dead defensive code in
  a bound is a claim nobody can check.

Three claims that were false or overstated, all found by review:

- `docs/PHI.md` said the `closed` reason is `eof` or `idle_timeout` and that "no
  path produces any other". This change adds `frame_deadline`, and nothing in CI
  pinned the sentence, so the false enumeration would have shipped in the record
  an assessor reads. Now states the vocabulary without the closed claim.
  `test_connection_event_row_names_exactly_the_shipped_kinds` reads every
  backticked token in that cell as an event KIND, so `frame_deadline` joins `eof`
  in the close-reason exemption -- verified still red on a planted bad kind.
- The `_per_host` comment implied the cap bounds a peer cycling source addresses.
  It does not: it keys on the address, so eight addresses restore the full 256.
  What the key-dropping bounds is the TABLE, which is keyed by an attacker-chosen
  value. The constant now says plainly what the cap does not cover.
- `DEFAULT_MAX_CONNECTIONS_PER_HOST` claimed thirty-two sockets from one address
  "is not a shape ordinary NAT'd traffic produces". False behind a source-NAT
  proxy, where every partner arrives as one peer and 32 becomes the effective
  listener capacity while the operator reads 256 in their config. Recorded as a
  real cost with the two things that make it survivable: the refusal names itself,
  and `None`/`0` is the documented setting for that topology.

Also: the 60 s deadline does not derive from the 16 MiB byte cap, and the pair
needs about 2.2 Mbps to be satisfiable. Both are now documented as one bound in
two units -- raise the seconds when you raise the bytes. A start-time warning is
deliberately not built: the honest threshold is a link speed the engine cannot
see. The review cited two samples as evidence here; both are OUTBOUND, where
`max_frame_seconds` does not apply, so the tension is real but that support is not.

Two enumerations that contradicted each other ("four resource caps" in the
connector, "five keys" in the factory) are gone rather than reconciled, per
SDS-3.6, and the repeated explanation now sits once on each constant with the
other sites pointing at it (SDS-3.5). The ratio pin moves from `* 4` to `* 8`,
which is the eighth both the constant and the docs commit to; at `* 4` a per-host
default of 64 would have passed while falsifying all three statements.

KNOWN AND OUT OF SCOPE: `transports/tcp.py` carries the same read loop and the
same unbounded-frame defect, and the raw-TCP/X12/HTTP/DICOM intakes have no
frame-life bound. `docs/CONNECTIONS.md` says so in words. Lifting the three
helpers into a shared place would fix all four at once and is worth its own row;
`_MessagePacer` is the precedent for sharing this shape rather than forking it.

Items 1, 2 and 4 of BACKLOG #1725. Item 3 (a per-listener in-flight ingress
semaphore) is written as "Consider" and is deliberately left out of scope.
…two claims

Review round 2 of two, the last. Everything confirmed here is applied; what was
declined is named below with the reason.

Behaviour:

- A negative `max_connections_per_host` or `max_frame_seconds` is now refused at
  build. Negative is TRUTHY, so it survived the `if value` that reads 0 as "off".
  At -1 the per-host cap refused every peer including one holding no connections
  (`0 >= -1`), and because nothing was ever admitted nothing ever cleared
  `_host_capacity_warned` -- one attacker-chosen key per source address, the
  unbounded table this control is not allowed to contain. Caught at dry-run and
  `messagefoundry check` rather than at the first connection.
- The TimeoutError arm now names the bound that ARMED the wait instead of
  re-measuring after it fired. Re-measuring loses at the boundary: with the two
  budgets close, the frame's remaining life reads as a small positive number and a
  peer that was never idle closes as `idle_timeout` -- the exact misdiagnosis the
  reason exists to prevent.
- `stop()` clears the per-host tables. A client task cancelled past its grace, or
  one the runner ABANDONS when a stop overruns and then reuses the same instance
  at the next promotion, never runs its `finally`; a stale count would survive
  into the restarted listener and lock that address out permanently. `_active`
  keeps its existing behaviour, being the pre-existing global cap's counter.

Two claims corrected, both mine:

- `docs/SECURITY.md`'s ingest row said no per-peer bound exists and enumerated the
  caps that ship on. This change falsified both. It now distinguishes the per-peer
  MESSAGE-RATE bound (still absent, still for the NAT reason) from the per-peer
  CONNECTION bound this row adds, and the enumeration is prefixed "at least".
  CONNECTIONS.md and PHI.md were updated last round and this one was not, which is
  the restatement cost landing on its first edit.
- `DEFAULT_MAX_FRAME_SECONDS` implied it bounds any slot-holding trickle. It does
  not: the clock starts at a START byte, and inter-frame noise is discarded
  without opening a frame, so a peer trickling non-MLLP bytes is neither idle nor
  in-frame. It buffers nothing and `max_connections_per_host` bounds how many
  slots one address holds that way, but the key does not cover it and now says so.
  Closing it needs a bound on connected time without a completed message, which is
  a different unit and a separate decision.

Also: the top-of-loop deadline comment claimed the trickle case, which the
TimeoutError arm actually handles; the duplicated final assertion in the pipelined
test is gone; the pipelined test no longer leaves a dangling open frame racing the
ACK drain, which could have fired `frame_deadline` and failed the test pointing at
a defect that was not there; the NAT guidance is trimmed to one statement plus
pointers (SDS-3.5); and the inventory docstring's count of pinned defaults is gone
rather than corrected (SDS-3.6).

DECLINED, with reasons:

- `transports/tcp.py` carries the same read loop, the same trickle exposure, no
  per-host term, and the same cancellation window this row closed for MLLP. Out of
  scope by the brief, and `docs/CONNECTIONS.md` says in words that the raw-TCP,
  X12, HTTP and DICOM intakes have no frame-life bound. Lifting the helpers to a
  shared layer fixes all four at once and is worth its own row; `_MessagePacer` is
  the precedent for sharing this shape rather than forking it.
- Both inbound-only caps are written into the settings dict for outbound MLLP too,
  where nothing reads them. That shape predates this row for `max_connections` and
  `receive_timeout`; closing it is a wiring refusal, which is a behaviour change
  wider than this row.
- `_log_frame_deadline` stays unthrottled. Reaching it costs a connection held for
  `max_frame_seconds`, so the caps already bound it to about 32 lines per minute
  per address; the throttled path could be driven as fast as `connect()` returns.
  Same shape as the `frame_oversize` warning beside it.

PROPOSED PR TITLE:
fix(mllp): bound an open frame's life and one peer's connections (BACKLOG #1725)

PROPOSED LEDGER BANNER:
BUILT 2026-09-19 -- the MLLP listener now bounds an open frame start-byte to
end-byte (`max_frame_seconds`, 60 s) and concurrent connections from one peer
address (`max_connections_per_host`, 32, an eighth of the socket cap). Items 1, 2
and 4 only. ITEM 3 -- a per-listener in-flight ingress semaphore -- was written as
"Consider" and is DELIBERATELY LEFT OUT OF SCOPE; it is a design question about
making the aggregate handling peak a setting rather than a product of two others,
and it wants its own row. `receive_timeout` is unchanged and still fires
independently on an idle socket: it resets on every byte received, so it bounds
silence and the new deadline bounds the frame, and both apply. The deadline
restarts per frame, so a pipelined sender is unaffected. Two limits are recorded
at the constants rather than left implied: the per-host cap keys on source
address, so it raises the floor on a single-address peer and does not bound a
distributed one, and behind a source-NAT proxy it becomes the effective listener
capacity (set it to `None`/`0` there); and the frame deadline starts at a START
byte, so a peer trickling non-MLLP noise is outside it. `transports/tcp.py` has
the same defects and is untouched.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

SECURITY-RECORD REWORDING VERIFIED FOR ACCURACY, not merely for consistency. Read by the Lander at
2026-09-19 20:50Z against head 2e2280b, because the batch-72 handoff flagged that this PR edits two
records carrying claims it falsifies.

docs/SECURITY.md -- the old row listed, under "Still not covered even when set", any per-peer
bound
. This PR adds one, so that line would have become false. The change NARROWS it to any
per-peer MESSAGE-RATE bound
, which is the accurate claim: max_connections_per_host is a
CONNECTION bound, a different unit, and the rewrite says so in those terms -- it caps concurrent
sockets from one address, refuses pre-ingress, and never bounds how fast an admitted peer may send.

Three things it does right that a merely-consistent edit would have got wrong:

  1. It keeps the NAT objection instead of dropping it. The new control keys on source IP, so the
    same collapse applies, and the text says so and names the remedy (None/0 behind a source-NAT
    proxy). A compensating control must not rest on a false premise -- SDS-3.7.
  2. It names which intakes do NOT carry the term (raw-TCP, X12, HTTP, DICOM), so the reader
    cannot generalise one listeners bound into a plane-wide one.
  3. It converts the "Resource bounds that DO ship on" enumeration to "at least ..." -- SDS-3.6,
    a completeness claim is a liability. That was not required by this change and is correct anyway.

It also adds max_frame_seconds with the distinction that actually matters: one frame start-byte to
end-byte, where receive_timeout resets on every byte received and so bounds only SILENCE. That is
the defect this PR exists to close, stated where a reader of the control table will meet it.

docs/PHI.md -- the connection_event row previously read reason as eof or idle_timeout,
with the parenthetical "no path produces any other". This change adds frame_deadline, which
falsifies that exactly. The rewrite lists all three and replaces the closed-world claim with "a fixed
vocabulary chosen by the listener, never free text", which is the property a PHI reader needs.

Not enqueued yet -- the merge queue is full at five. Nothing here blocks it.

@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Sep 19, 2026
…dline together

PR 1229 (BACKLOG #290, merged 21:22:47Z) named the pacer so its throttled
WARNING can say which inbound is being held back. This branch (BACKLOG #1725)
adds a per-frame deadline in the same MLLP read loop.

One conflicted hunk, in MLLPSource._on_client where the pacer is built:
- took main's _MessagePacer.for_rate(..., name=self._pacing_name);
- kept this branch's frame_opened_at stamp directly after it.

Both bounds stay. The loop still pushes frame_opened_at forward by the time
pace() withheld, so pacing never spends the peer's frame budget. pace() now
also tallies and logs through _note_paced before its sleep, which changes no
timing. docs/PHI.md is byte-identical to this branch's head; docs/SECURITY.md
auto-merged and keeps the per-peer MESSAGE-RATE narrowing and the
max_connections_per_host NAT sentence.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Merged origin/main (a087dea) in as c3705f2, a plain merge commit, no rebase or force-push.

Resolution: one conflicted hunk, in MLLPSource._on_client where the pacer is built. I took PR 1229's _MessagePacer.for_rate(..., name=self._pacing_name) and kept this branch's frame_opened_at stamp right after it. Both bounds stay: the loop still moves frame_opened_at forward by the time pace() withheld, and 1229's _note_paced logs before the sleep without changing timing. docs/PHI.md is byte-identical to the previous head. docs/SECURITY.md auto-merged and keeps the per-peer MESSAGE-RATE narrowing and the max_connections_per_host NAT sentence.

Checks run before the commit:

  • ruff check, ruff format --check: clean
  • mypy messagefoundry (strict): no issues, 275 files
  • pytest tests/test_mllp_message_pacing.py, test_connection_event_emit.py, test_ingress_message_pacing.py, test_communications_inventory.py: 154 passed
  • pytest tests/test_phi_logging_inventory.py, test_wiring.py, tests/test_mllp*.py: 217 passed, 3 skipped

Skipped: the full suite and every hosted-only leg. Read CI for those.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-red A required check went red. Attribute it before retrying.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant