From 75789c6f9989ff8de76988c5d312979ca5e27853 Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Mon, 15 Jun 2026 14:41:44 -0400 Subject: [PATCH] net: record bytes written before a write error (last_write_sent) (#27460) * net: record bytes written before a write error (last_write_sent) Each connection's write_ptr accumulates a running total of bytes sent but discarded it on the error path, so a higher layer could not tell a zero-byte failure (nothing reached the peer) from a partial write. That distinction is needed to safely retry: a non-idempotent request is only replayable when provably zero bytes were sent. Add a `last_write_sent int` field to TcpConn and the openssl/mbedtls SSLConn, set as write_ptr accumulates total_sent (reset to 0 on entry), so it is valid on every exit including errors. Purely additive: existing write/write_ptr/write_string return values and error semantics are unchanged. Closes #27459. Co-Authored-By: WOZCODE * net: mark last_write_sent indeterminate on failed TLS writes A failed TLS write cannot guarantee zero bytes reached the peer: OpenSSL's SSL_write may already have flushed complete records (which the server can decrypt and act on) before returning a retryable WANT_READ/WANT_WRITE, so a subsequent wait timeout left last_write_sent at 0 and would have let a caller misclassify a partial write as a zero-byte failure. last_write_sent is now tri-state: 0 = provably nothing sent (safe to replay), >0 = exact bytes (plain TCP, send() is byte-accurate), and -1 = indeterminate. The openssl and mbedtls backends set -1 as soon as a write does not fully complete (reset to the exact length on a later full success); plain TCP keeps exact counts and never reports -1. Co-Authored-By: WOZCODE --------- Co-authored-by: WOZCODE --- vlib/net/mbedtls/ssl_connection.c.v | 13 ++++++++ vlib/net/openssl/ssl_connection.c.v | 14 +++++++++ vlib/net/tcp.c.v | 8 +++++ vlib/net/tcp_last_write_sent_test.v | 49 +++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 vlib/net/tcp_last_write_sent_test.v diff --git a/vlib/net/mbedtls/ssl_connection.c.v b/vlib/net/mbedtls/ssl_connection.c.v index 9fda881a3..82021b660 100644 --- a/vlib/net/mbedtls/ssl_connection.c.v +++ b/vlib/net/mbedtls/ssl_connection.c.v @@ -155,6 +155,12 @@ pub mut: // strings in config.alpn_protocols. mbedtls stores this pointer without // copying, so it must outlive the SSL config; it is freed in shutdown(). alpn_list &&char = unsafe { nil } + // last_write_sent reports the most recent write_ptr's progress for retry + // decisions: 0 = provably nothing was sent (safe to replay), or -1 = the + // count is indeterminate because a failed/retryable write may have already + // flushed a record to the peer (TLS cannot prove zero). On full success it + // equals the bytes written. + last_write_sent int } // SSLListener listens on a TCP port and accepts connection secured with TLS @@ -858,6 +864,7 @@ pub fn (mut s SSLConn) write_ptr(bytes &u8, len int) !int { } } + s.last_write_sent = 0 deadline := ssl_timeout_deadline(s.duration) unsafe { mut ptr_base := bytes @@ -866,6 +873,11 @@ pub fn (mut s SSLConn) write_ptr(bytes &u8, len int) !int { remaining := len - total_sent mut sent := C.mbedtls_ssl_write(&s.ssl, ptr, remaining) if sent <= 0 { + // The write did not fully complete; a retryable error can leave a + // record partially flushed, so the sent count is no longer + // provable. Mark it indeterminate (a later full success below + // resets it to the exact length). + s.last_write_sent = -1 match sent { C.MBEDTLS_ERR_SSL_WANT_READ { s.wait_for_read(ssl_remaining_timeout(deadline))! @@ -885,6 +897,7 @@ pub fn (mut s SSLConn) write_ptr(bytes &u8, len int) !int { } } total_sent += sent + s.last_write_sent = total_sent } } return total_sent diff --git a/vlib/net/openssl/ssl_connection.c.v b/vlib/net/openssl/ssl_connection.c.v index b7189e4d3..5fa43a353 100644 --- a/vlib/net/openssl/ssl_connection.c.v +++ b/vlib/net/openssl/ssl_connection.c.v @@ -16,6 +16,12 @@ pub mut: duration time.Duration owns_socket bool + // last_write_sent reports the most recent write_ptr's progress for retry + // decisions: 0 = provably nothing was sent (safe to replay), or -1 = the + // count is indeterminate because a failed/retryable write may have already + // flushed complete records to the peer (TLS cannot prove zero). On full + // success it equals the bytes written. + last_write_sent int } @[params] @@ -413,6 +419,7 @@ pub fn (mut s SSLConn) write_ptr(bytes &u8, len int) !int { } } + s.last_write_sent = 0 deadline := ssl_timeout_deadline(s.duration) unsafe { mut ptr_base := bytes @@ -421,6 +428,12 @@ pub fn (mut s SSLConn) write_ptr(bytes &u8, len int) !int { remaining := len - total_sent mut sent := C.SSL_write(voidptr(s.ssl), ptr, remaining) if sent <= 0 { + // SSL_write did not fully complete: OpenSSL may already have + // flushed one or more complete records (which the peer can + // decrypt and act on) before returning a retryable error, so the + // sent count is no longer provable. Mark it indeterminate; a + // later full success below resets it to the exact length. + s.last_write_sent = -1 err_res := ssl_error(sent, s.ssl)! if err_res == .ssl_error_want_read { s.wait_for_read(ssl_remaining_timeout(deadline))! @@ -441,6 +454,7 @@ pub fn (mut s SSLConn) write_ptr(bytes &u8, len int) !int { int(err_res)) } total_sent += sent + s.last_write_sent = total_sent } } return total_sent diff --git a/vlib/net/tcp.c.v b/vlib/net/tcp.c.v index 24e7103a4..a6c4b6d4d 100644 --- a/vlib/net/tcp.c.v +++ b/vlib/net/tcp.c.v @@ -32,6 +32,12 @@ pub mut: read_timeout time.Duration write_timeout time.Duration is_blocking bool = true + // last_write_sent is the exact number of bytes the most recent write_ptr + // call sent, valid even when it then returned an error (send() is + // byte-accurate), so a caller can tell a zero-byte failure — 0, safe to + // replay — from a partial write. (The TLS backends expose the same field but + // use -1 for the indeterminate case they cannot prove; plain TCP never does.) + last_write_sent int } // dial_tcp will try to create a new TcpConn to the given address. @@ -233,6 +239,7 @@ pub fn (mut c TcpConn) write_ptr(b &u8, len int) !int { '>>> TcpConn.write_ptr | data.len: ${len:6} | hex: ${unsafe { b.vbytes(len) }.hex()} | data: ' + unsafe { b.vstring_with_len(len) }) } + c.last_write_sent = 0 unsafe { mut ptr_base := &u8(b) mut total_sent := 0 @@ -260,6 +267,7 @@ pub fn (mut c TcpConn) write_ptr(b &u8, len int) !int { } } total_sent += sent + c.last_write_sent = total_sent } return total_sent } diff --git a/vlib/net/tcp_last_write_sent_test.v b/vlib/net/tcp_last_write_sent_test.v new file mode 100644 index 000000000..568f7ba55 --- /dev/null +++ b/vlib/net/tcp_last_write_sent_test.v @@ -0,0 +1,49 @@ +import net +import time + +// last_write_sent must reflect the bytes the most recent write managed to send, +// both on success and (crucially) when the write fails, so a caller can tell a +// zero-byte failure from a partial write. See vlang/v#27459. + +fn drain_one_conn(mut l net.TcpListener) { + mut c := l.accept() or { return } + for { + mut buf := []u8{len: 4096} + c.read(mut buf) or { break } + } + c.close() or {} +} + +fn test_last_write_sent_on_success() { + mut l := net.listen_tcp(.ip, '127.0.0.1:0')! + addr := l.addr()! + th := spawn drain_one_conn(mut l) + mut c := net.dial_tcp(addr.str())! + payload := 'hello world'.bytes() + n := c.write(payload)! + assert n == payload.len + assert c.last_write_sent == payload.len + c.close() or {} + l.close() or {} + th.wait() +} + +fn test_last_write_sent_is_zero_on_failed_write_to_closed_conn() { + mut l := net.listen_tcp(.ip, '127.0.0.1:0')! + addr := l.addr()! + th := spawn drain_one_conn(mut l) + mut c := net.dial_tcp(addr.str())! + // Close our side, then attempt a write: it must fail and report zero bytes + // sent, so a retry layer can treat it as safe to replay. + c.close() or {} + time.sleep(5 * time.millisecond) + c.write('data'.bytes()) or { + assert c.last_write_sent == 0, 'a failed write before any byte left must report 0' + l.close() or {} + th.wait() + return + } + assert false, 'write to a closed connection unexpectedly succeeded' + l.close() or {} + th.wait() +} -- 2.39.5