Skip to content

Commit 42b2ad5

Browse files
committed
fix(receive): tcp read '0' is a no-op
In Posix a TCP read of 0 bytes shouldn't wait for a socket to become readable, it should instantly return.
1 parent e4bbc7b commit 42b2ad5

3 files changed

Lines changed: 51 additions & 0 deletions

File tree

docs/index.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ <h2><a name="history"></a>History</h2>
102102

103103
<dt><strong>Copas 4.x.x</strong> [unreleased]</dt>
104104
<dd><ul>
105+
<li>Fix: a 0-byte <code>receive(0)</code> on a TCP/SSL socket was treated like a normal
106+
read and queued the caller on <code>select</code>, blocking until more data arrived even
107+
though nothing was needed. Per POSIX, a 0-byte read on a stream socket is a no-op; it now
108+
returns <code>""</code> immediately (#223).</li>
105109
<li>Fix: <code>copas.http</code> included the URL fragment in the request line sent to
106110
the origin server (and, in proxy mode, to the proxy). Fragments are client-side only and
107111
must never be transmitted on the wire; a fragment containing sensitive data (e.g. an OAuth

src/copas.lua

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,15 @@ local isTCP do
542542
end
543543

544544

545+
-- POSIX: a 0-byte read on a stream socket (TCP/SSL) is a no-op and must
546+
-- return immediately without waiting on `select`. UDP is different: a
547+
-- 0-byte datagram is a distinct payload, so it must still wait.
548+
-- See https://github.com/lunarmodules/copas/issues/223
549+
local function is_zero_byte_tcp_read(client, pattern)
550+
return pattern == 0 and isTCP(client)
551+
end
552+
553+
545554
function copas.close(skt, ...)
546555
_closed[#_closed+1] = skt
547556
return skt:close(...)
@@ -609,6 +618,11 @@ end
609618
-- untrusted/remote input without an application-enforced size limit; use a
610619
-- numeric (sized) pattern or receivepartial with your own cumulative cap instead.
611620
function copas.receive(client, pattern, part)
621+
if is_zero_byte_tcp_read(client, pattern) then
622+
copas.pause() -- yield so a tight receive(0) loop can't starve other coroutines
623+
return part or "", nil, nil
624+
end
625+
612626
local s, err
613627
pattern = pattern or "*l"
614628
local current_log = _reading_log
@@ -704,6 +718,11 @@ end
704718
-- same as above but with special treatment when reading chunks,
705719
-- unblocks on any data received.
706720
function copas.receivepartial(client, pattern, part)
721+
if is_zero_byte_tcp_read(client, pattern) then
722+
copas.pause() -- yield so a tight receive(0) loop can't starve other coroutines
723+
return part or "", nil, nil
724+
end
725+
707726
local s, err
708727
pattern = pattern or "*l"
709728
local orig_size = #(part or "")

tests/tcptimeout.lua

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,34 @@ function tests.receive_timeout()
159159
end
160160

161161

162+
-- See issue https://github.com/lunarmodules/copas/issues/223
163+
-- A 0-byte read on a TCP socket is a POSIX no-op: it must return
164+
-- immediately without waiting for the socket to become readable, unlike a
165+
-- normal (>0 byte) read which would sit in the `select` wait set.
166+
function tests.receive_zero_bytes_returns_immediately()
167+
local ip, port = singleuseechoserver()
168+
169+
copas.addthread(function()
170+
local client = socket.tcp()
171+
client = copas.wrap(client)
172+
-- long enough that hitting it would mean the bug is back
173+
client:settimeout(1)
174+
local status, err = client:connect(ip, port)
175+
assert(status, "failed to connect: "..tostring(err))
176+
177+
-- nothing is ever sent by the peer, so a normal read would time out
178+
local start = copas.gettime()
179+
local data, err = client:receive(0)
180+
assert(data == "", "expected an immediate empty read, got: "..tostring(data)..", err: "..tostring(err))
181+
assert(copas.gettime() - start < 0.5, "receive(0) waited on the socket instead of returning immediately")
182+
183+
client:close()
184+
end)
185+
186+
copas.loop()
187+
end
188+
189+
162190
function tests.receive_timeout_clears_copas_timeout()
163191
-- See issue https://github.com/lunarmodules/copas/issues/185
164192
local server = socket.bind("127.0.0.1", 0)

0 commit comments

Comments
 (0)