Skip to content

fix(server): defeat sendfile to avoid corrupted GET responses on Darwin#382

Open
spencer-scw wants to merge 1 commit into
claudiodangelis:mainfrom
spencer-scw:fix-darwin-sendfile-scramble
Open

fix(server): defeat sendfile to avoid corrupted GET responses on Darwin#382
spencer-scw wants to merge 1 commit into
claudiodangelis:mainfrom
spencer-scw:fix-darwin-sendfile-scramble

Conversation

@spencer-scw

@spencer-scw spencer-scw commented May 27, 2026

Copy link
Copy Markdown

AI usage disclaimer: I used Claude Code to diagnose and patch a bug I was having on macOS sending an image. The patch seems reasonable to me, but I don't fully understand the specifics of the upstream bug in Golang it identified. The patch is simple though- it just short-ciruits the faulty upstream code path with a simpler working one. AI also left a fairly verbose comment in the code explaining how it works, so just let me know if you want it removed. It does provide good documentation into the decision being made, so it might be worth leaving until a golang fix is merged upstream.

Summary

qrcp send currently produces a corrupted response body for every GET when bound to a non-loopback interface (the default). With a 29 KB JPEG, the response bytes come back in the order [file[512:], HTTP/1.1 headers, file[:512]]. curl rejects with Received HTTP/0.9 when not allowed and browsers render the bytes as garbled binary. Affects every file > 512 bytes.

Root cause is a Go stdlib bug in the interaction between http.ServeFile and macOS sendfile(2) on non-loopback interfaces. It's reproducible in an 8-line program with no third-party code. I plan to file it upstream against golang/go separately.

This PR works around it in qrcp by hiding ReadFrom from the accepted *net.TCPConn, which causes net/http's response writer to fall back from sendfile to a plain Write() loop. That path serializes headers and body in the correct order. Small per-byte overhead vs. sendfile, irrelevant for LAN file transfers.

Reproduction (before the patch)

With qrcp v0.11.6 / current main, on macOS, sending any file larger than 512 bytes:

$ qrcp send -k test.jpg   # 29 KB JPEG
# scan QR or copy URL, then:
$ curl http://<lan-ip>:<port>/send/<path>
curl: (1) Received HTTP/0.9 when not allowed

Raw socket inspection confirms the body+headers reordering:

$ printf 'GET /send/<path> HTTP/1.1\r\nHost: ...\r\nConnection: close\r\n\r\n' \
    | nc -w 5 <lan-ip> <port> > resp.bin
$ python3 -c "
import sys
data = open('resp.bin','rb').read()
src  = open('test.jpg','rb').read()
http_off = data.find(b'HTTP/1.1')
header_end = data.find(b'\r\n\r\n', http_off) + 4
print('Bytes before HTTP marker == file[512:]:', data[:http_off]   == src[512:])
print('Bytes after  headers    == file[:512]:', data[header_end:] == src[:512])
"
Bytes before HTTP marker == file[512:]: True
Bytes after  headers    == file[:512]: True

After the patch

$ curl -O http://<lan-ip>:<port>/send/<path>
$ cmp downloaded.jpg test.jpg   # byte-identical
$ file downloaded.jpg
downloaded.jpg: JPEG image data, baseline, precision 8, 500x333, components 3

Test plan

  • qrcp send file.jpg on macOS, file > 512 bytes, downloads cleanly via curl
  • Downloaded file is byte-identical to source (cmp)
  • qrcp send -z file1 file2 zip path still works
  • qrcp receive upload path still works (touches the same listener)
  • HEAD requests still return correct headers
  • Verify on Linux that behavior is unchanged (Linux uses splice, not sendfile; should not be impacted by this change either way)

🤖 Generated with Claude Code

http.ServeFile on Darwin currently corrupts every send/ GET response on
qrcp's default code path. With qrcp bound to a LAN interface (the normal
case), the wire bytes come back in the order
[file[512:], HTTP/1.1 headers, file[:512]], which curl rejects as
"Received HTTP/0.9 when not allowed" and browsers render as garbled binary.

The root cause is in the Go stdlib's interaction with macOS sendfile(2)
on non-loopback interfaces, reproducible in a bare 8-line http.HandleFunc
+ http.ServeFile program. Files <=512 bytes and loopback bindings are
unaffected.

Hiding ReadFrom from the accepted *net.TCPConn forces net/http's response
writer to fall back to a plain Write() loop, which serializes headers and
body in the correct order. The small per-byte overhead vs. sendfile is
negligible for the LAN file-transfer use case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@spencer-scw spencer-scw marked this pull request as ready for review May 27, 2026 20:24
@claudiodangelis

Copy link
Copy Markdown
Owner

Good catch!
Thanks for taking the time to open the PR.
I will review soon,
C

@spencer-scw

Copy link
Copy Markdown
Author

It looks like the CI failures are pre-existing pending #381

@claudiodangelis claudiodangelis self-requested a review June 13, 2026 17:38
@claudiodangelis

Copy link
Copy Markdown
Owner

LGTM, I will publish this in the next update.
Thanks for taking the time to be on this,
C

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants