Skip to content

fix: finish a socket write instead of dropping its tail - #689

Merged
kacy merged 1 commit into
mainfrom
fix-partial-writes-and-redis-crlf
Aug 9, 2026
Merged

fix: finish a socket write instead of dropping its tail#689
kacy merged 1 commit into
mainfrom
fix-partial-writes-and-redis-crlf

Conversation

@kacy

@kacy kacy commented Aug 9, 2026

Copy link
Copy Markdown
Owner

tcp_write and tcp_write_bytes are a single write(2). The kernel takes what fits in the socket send buffer and returns that count; the rest is not queued anywhere. Across std/ no caller looped — std/postgres.pith was the only site that even captured the count, and only to check is_err. So a buffer larger than the send buffer lost its tail silently and the peer waited for bytes that were never written. That is the reported redis symptom: a 1 MiB SET works, a 4 MiB one hangs.

std.net.tcp gains write_all and write_all_bytes. A short write is treated as normal rather than as an error — the loop resumes from where the last one stopped — and the only condition that ends it early is a write that accepts nothing at all, which means the reader is gone. That matches what fdio::write_channel in the runtime already promised: it returns 0 only for a real close, and waits on the reactor rather than returning 0 for a would-block. This was a std-layer omission, not a runtime one, so nothing under cranelift/ changed.

Adopted at every site that wrote straight to the builtins: std/redis.pith, std/postgres.pith (both the framed send path and the pre-TLS SSLRequest), std/mysql.pith, the h2c client preface in std/web.pith, and the plaintext transports in std/net/http2/connection.pith and std/net/http2/server.pith. std/net/tcp.pith's own write stays a single syscall and now documents that loudly, since the count is the whole result and a caller may legitimately want it.

Looping on bytes rather than on text turned out to matter more than the missing loop did. std/io.pith already looped correctly, but it resumed by re-slicing the String. A send buffer fills at whatever byte offset it fills at, including the middle of a multi-byte character, and a String cannot be cut there — substring at a non-boundary offset stops the process. TcpStream.write_all on three megabytes of non-ascii died outright, and the buffered TCP writer flushes through that same helper. The text write_all helpers now encode once and resume through their bytes counterpart.

The TLS write path was already correct and is unchanged. Conn.write_bytes caps at one 16 KiB record, so it is a partial write by construction, but Conn.write_all_bytes loops over it and its socket writes go through std.io's byte loop.

Separately, the redis reply parser read line[0] for the reply tag with nothing checking the line was non-empty. read_line returns what came before the terminator, so a bare CRLF handed it "", and indexing a String is strict: a peer sending two bytes killed the client process. Every other malformed shape in that parser — unknown tag, bad length, truncated payload, exceeded limit — comes back as an ordinary error, and now this one does too.

what was tested

The 4 MiB hang was reproduced first. A stub server that accepts and then drains slower than the client fills gives a short write of 2,625,195 of 4,194,304 bytes under PITH_GREEN=1, and the redis SET on top of it times out at 60s (exit 124). Under PITH_GREEN=0 the same case completes, because that backend's socket is blocking — the bug only bites on green, which is the default.

Two regression cases were added, and each fix was checked by breaking it again:

  • tests/cases/test_tcp_write_all.pith sends 4 MiB of ascii and 3 MiB of three-byte characters to a deliberately slow reader, through the fd helper, through TcpStream.write_all, and through a redis SET. Reverting the redis call to a bare tcp_write makes it time out at exit 124. Reverting the helper's resume to a single write makes the first case hang. Restoring std/io.pith's string-based resume makes it die with substring(2672619, 3145729) would split the character '€'. The non-ascii payload carries a one-byte ascii prefix so the first short write cannot land on a character start.
  • tests/cases/test_redis_bare_crlf.pith drives a stub that replies with a bare CRLF, a lone LF, and an empty line nested inside an array. Each returns an error, and the process is still running at the end to say so. With the guard removed it dies with string index out of bounds: 0 for string of length 0, exit 1.

make run-regressions-only is 331/331, up from 329 by the two new cases. Both new cases produce byte-identical output under PITH_GREEN=1, PITH_GREEN=1 PITH_GREEN_WORKERS=1 and PITH_GREEN=0, so the corpus verification under both backends covers them. make memcheck is clean, and both new cases were additionally run under valgrind directly, since they touch the socket path and the redis parser. The four edited examples run, and web_h2 and redis_client still match their expected output.

tcp_write and tcp_write_bytes are a single write(2). the kernel takes what
fits in the send buffer and returns that count; the rest is not queued
anywhere. every std caller discarded the count, so a buffer larger than the
send buffer lost its tail silently and the peer waited for bytes that were
never written. a 1 MiB redis SET worked; a 4 MiB one hung.

std.net.tcp grows write_all and write_all_bytes, which resume from where the
last write stopped and only give up when a write accepts nothing at all —
that is the reader having gone, and it is the only condition that ends the
loop early. redis, postgres, mysql, the h2c client preface and both http/2
plaintext transports now write through it.

the loop runs on bytes rather than on text, which turned out to matter more
than the missing loop did. std.io already looped correctly, but it resumed by
re-slicing the String, and a send buffer fills at whatever byte offset it
fills at — including the middle of a multi-byte character, which a String
cannot be cut at. TcpStream.write_all on three megabytes of non-ascii stopped
the process outright. the text write_all helpers now encode once and resume
through their bytes counterpart.

the tls write path was already right: Conn.write_bytes caps at one 16 KiB
record, but Conn.write_all_bytes loops over it and its socket writes go
through std.io's byte loop.

separately, the redis reply parser read line[0] to get the tag with nothing
checking the line was non-empty. read_line returns what came before the
terminator, so a bare CRLF handed it "", and indexing a String is strict —
a peer sending two bytes killed the client process, while every other
malformed shape in the same parser came back as an ordinary error.
@kacy
kacy merged commit 8d6ca03 into main Aug 9, 2026
2 checks passed
@kacy
kacy deleted the fix-partial-writes-and-redis-crlf branch August 9, 2026 18:17
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.

1 participant