Skip to content

recv() caps its X'75' chunk at 4096, but only 256 or less is safe against a restarted copy #154

Description

@mgrossmann

What is wrong

src/dyn75/@@75recv.c caps each X'75' RECV at 4096 bytes:

/* we have to limit the recv() length to no more than
 * 4096 bytes due to a limitation of the dyn75 interface
 * and the Hercules emulator.
 * If we don't do this then the buf is overwritten from
 * the start after 4096 bytes are received.
 */
chunk = len - read;
if (chunk > 4096) chunk = 4096;

The symptom in that comment is real. The cap does not prevent it, and the stated
cause is not the cause.

The safe bound is 256. Anything above it can corrupt, including 4096.

Why 4096 does not work

x75.c copies host buffer to guest buffer in 256-byte segments, and the
instruction is restartable: a page fault on the guest buffer is a nullifying
exception, so MVS resolves the page and the instruction runs again from the top.
R1 (bytes remaining) and the base register are advanced across that restart, so
the guest side resumes correctly. The host-side pointer is not: it is recomputed
from map32 [R2] on every entry and R2 is a slot index that never advances. The
remaining bytes are therefore copied from the start of the host buffer to
the already advanced guest address.

So the corruption needs one completed segment before the fault -- i.e. a
transfer above 256 bytes -- and its likelihood scales with how many guest pages
the copy touches. That is the whole of the observed size correlation, and it is
why every cap so far has looked like a fix and then failed:

  • this file, 4096, since cd43a70 (December 2024)
  • mvslovers/mvsmf then hit the same corruption at >2048 with that cap in
    place
    , capped at 2048 (d2783f5), and that failed five days later
    (4bc1014)
  • mvsmf's receive_raw_data() has read one byte per recv() ever since,
    which is immune for the same reason 256 is: a single segment that faults has
    copied nothing, and restarts correctly

vstorec resolves both page addresses through MADDRL before either memcpy,
so a segment is atomic against a translation exception. That is what makes
<= 256 immune by construction rather than merely less likely.

Full analysis, the emulator-side fix and the corroborating independent sighting
(twinslow/mvs_nfsd, socktest/, same X'75' layer, different application):
mvslovers/hyperion, TODO.md, "a restarted copy replays the host buffer from
its start".

The change

if (chunk > 256) chunk = 256;

plus a comment stating the actual cause, because the present one sends the next
reader after a Hercules buffer-size limit that does not exist.

Is this independent of the emulator fix?

Yes, in both directions, and it should not wait for it.

  • On an unpatched emulator it is correct on its own -- no host change is
    involved in the argument above.
  • On a patched emulator it is redundant but harmless.
  • Neither change requires the other.

It is also permanent. A guest cannot detect the emulator fix -- there is no
return value, status bit or function code that distinguishes a patched emulator,
and adding one would change the interface for every existing guest. So this cap
can never be raised again on the strength of a fixed host, because the same
build has to keep working on an unfixed one.

Cost

More X'75' pairs per transfer: 16x against the current 4096. Against what
consumers actually do today it is a large net win -- for a 1.4 MB body, roughly
5700 pairs at 256, against roughly 1.47 million single-byte recv() calls in
mvsmf's present workaround. Once this lands, mvsmf can return
receive_raw_data() to bulk reads.

Not in scope here

@@75send.c has the same exposure and no cap at all (pl.r1 = len
directly), so a large send can be corrupted the same way -- the mirror image,
the host buffer losing its leading segments. It is left out deliberately:
send() returns a byte count and callers loop on partial writes, so capping it
changes what every caller sees per call rather than just how many instructions
are issued. That needs its own decision and its own issue.

The asymmetry in what has actually been observed is consistent with the
mechanism: a send buffer was just written by the application and is hot, while a
receive buffer can have been idle across an I/O wait, which is exactly when its
pages get stolen.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions