From 73b0780321acb14bab6707006f5136ea9998951d Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Thu, 30 Jul 2026 19:22:17 +0200 Subject: feat(receive): add maxsize argument to bound memory usage client:receive("*l") and receive("*a") are unbounded: a peer that never sends a newline, or never closes, makes LuaSocket buffer until the process runs out of memory (e.g. src/http.lua reading response headers in a loop). Add an optional maxsize argument that caps the payload a single call may accumulate, including prefix. - Hoist all argument validation ahead of timeout_markstart() so bad calls (maxsize < 1, #prefix >= maxsize, numeric pattern > maxsize) raise before any I/O and leave the socket untouched. - recvline/recvall take a budget and return a new internal BUF_OVERSIZED code, surfaced to Lua as the "oversized" error alongside "timeout"/"closed", with the partial held in the 3rd return value. - recvraw is left untouched: argument checks make the cap unreachable for numeric patterns. - Preserve three invariants: a timeout partial is always shorter than maxsize (safe to retry as prefix), completion beats the cap for *a, and no bytes are lost or skipped on overflow. - tcp.c, unixstream.c and serial.c all share this code path unchanged. Adds test coverage (argument errors, *l/*a boundaries, timeout/close at the cap, the drain idiom, numeric patterns, unix-stream mirror) and documents the new argument, error, and recovery idioms in docs/tcp.html. --- test/testclnt.lua | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++ test/utestclnt.lua | 45 ++++++++++++++ 2 files changed, 219 insertions(+) (limited to 'test') diff --git a/test/testclnt.lua b/test/testclnt.lua index 170e187..3e897c9 100644 --- a/test/testclnt.lua +++ b/test/testclnt.lua @@ -618,6 +618,177 @@ remote([[ pass("ok") end +------------------------------------------------------------------------ +function test_maxsize() + -- Group A: argument errors, raised before any I/O + reconnect() + printf("argument errors: ") + -- bounds pre-implementation calls (where arg 4 is silently dropped and + -- these become real, otherwise-unbounded blocking reads on an idle + -- socket) so a meaningful failure doesn't hang the suite + data:settimeout(0.2) + local ok + ok = pcall(data.receive, data, "*l", nil, 0) + assert(not ok, "A1 failed: maxsize=0 should raise") + ok = pcall(data.receive, data, "*l", nil, -1) + assert(not ok, "A2 failed: maxsize=-1 should raise") + ok = pcall(data.receive, data, "*l", nil, "abc") + assert(not ok, "A3 failed: non-number maxsize should raise") + ok = pcall(data.receive, data, "*l", string.rep("x", 10), 10) + assert(not ok, "A4 failed: #prefix == maxsize should raise") + ok = pcall(data.receive, data, "*l", string.rep("x", 11), 10) + assert(not ok, "A5 failed: #prefix > maxsize should raise") + ok = pcall(data.receive, data, 100, nil, 50) + assert(not ok, "A6 failed: wanted > maxsize should raise") + data:settimeout(0.1) + ok = pcall(data.receive, data, 50, nil, 100) + assert(ok, "A7 failed: wanted <= maxsize should not raise") + data:settimeout(-1) + remote [[ data:send('intact\n') ]] + local line, err = data:receive("*l", nil, 100) + assert(line == "intact", + "A8 failed: socket touched by a failed argcheck (err=" .. + tostring(err) .. ")") + pass("ok") + + -- Group B: *l boundary + reconnect() + printf("*l boundary: ") + remote [[ data:send('hello\n') ]] + local d, e, p = data:receive("*l", nil, 100) + assert(d == "hello" and e == nil, "B1 failed") + + remote(string.format([[data:send(string.rep('a',%d) .. '\n')]], 100)) + d, e = data:receive("*l", nil, 100) + assert(d == string.rep("a", 100) and e == nil, + "B2 failed: exact-budget line should succeed") + + remote(string.format([[data:send(string.rep('a',%d) .. '\n')]], 101)) + d, e, p = data:receive("*l", nil, 100) + assert(d == nil and e == "oversized" and p == string.rep("a", 100), + "B3 failed: one-over-budget should be oversized") + d, e = data:receive("*l", nil, 100) + assert(d == "a" and e == nil, + "B3 failed: leftover byte and terminator should still be there") + + remote(string.format([[data:send(string.rep('\r',%d) .. string.rep('a',%d) .. '\n')]], 50, 100)) + d, e = data:receive("*l", nil, 100) + assert(d == string.rep("a", 100) and e == nil, "B4 failed: CRs must not count") + + remote(string.format([[data:send(string.rep('a',%d) .. '\r\n')]], 100)) + d, e = data:receive("*l", nil, 100) + assert(d == string.rep("a", 100) and e == nil, "B5 failed: CRLF at boundary") + pass("ok") + + -- Group C: *l and I1 (timeout/close at the cap) + reconnect() + printf("I1 (timeout/closed at the cap): ") + remote(string.format([[data:send(string.rep('a',%d))]], 50)) + data:settimeout(0.5) + d, e, p = data:receive("*l", nil, 100) + assert(d == nil and e == "timeout", "C1 failed: expected timeout below cap") + assert(#p < 100, "C1 failed: timeout partial must be strictly < maxsize") + ok = pcall(data.receive, data, "*l", p, 100) + assert(ok, "C1 failed: retry with prefix=partial, same maxsize must not raise") + + reconnect() + remote(string.format([[data:send(string.rep('a',%d))]], 100)) + data:settimeout(0.5) + d, e, p = data:receive("*l", nil, 100) + assert(e == "oversized" and #p == 100, + "C2 failed: exactly-at-cap timeout must be oversized, got " .. tostring(e)) + + reconnect() + remote(string.format([[data:send(string.rep('a',%d)) data:close() data = nil]], 100)) + d, e, p = data:receive("*l", nil, 100) + assert(e == "oversized" and #p == 100, + "C3 failed: exactly-at-cap close must be oversized, got " .. tostring(e)) + pass("ok") + + -- Group D: drain idiom + -- 5030 (not a multiple of the 100 cap) so the boundary of the 50th + -- oversized chunk doesn't land exactly on the '\n': when it does, the + -- terminator check wins over the cap check (by design -- see B2/I1) and + -- the would-be 50th oversized chunk instead succeeds outright. + reconnect() + printf("drain idiom: ") + remote(string.format([[data:send(string.rep('x',%d) .. '\n' .. 'next\n')]], 5030)) + local iterations = 0 + repeat + d, e, p = data:receive("*l", "", 100) + if e == "oversized" then + assert(#p == 100, "D1 failed: oversized partial length " .. #p) + iterations = iterations + 1 + end + until e ~= "oversized" + assert(iterations == 50, + "D1 failed: expected 50 oversized iterations, got " .. iterations) + assert(d == string.rep("x", 30) and e == nil, "D1 failed: final call should succeed") + local nextline = data:receive("*l") + assert(nextline == "next", "D1 failed: stream misaligned, got " .. tostring(nextline)) + pass("ok") + + -- Group E: *a + reconnect() + printf("*a boundary: ") + remote [[ data:send('abc') data:close() data = nil ]] + d, e = data:receive("*a", nil, 100) + assert(d == "abc" and e == nil, "E1 failed") + + reconnect() + remote(string.format([[data:send(string.rep('a',%d)) data:close() data = nil]], 100)) + d, e = data:receive("*a", nil, 100) + assert(d == string.rep("a", 100) and e == nil, + "E2 failed: completion should beat the cap") + + reconnect() + remote(string.format([[data:send(string.rep('a',%d)) data:close() data = nil]], 250)) + d, e, p = data:receive("*a", "", 100) + assert(e == "oversized" and #p == 100, "E3 failed: first chunk") + d, e, p = data:receive("*a", "", 100) + assert(e == "oversized" and #p == 100, "E3 failed: second chunk") + d, e = data:receive("*a", "", 100) + assert(d == string.rep("a", 50) and e == nil, "E3 failed: final chunk") + + reconnect() + remote(string.format([[data:send(string.rep('a',%d))]], 100)) + data:settimeout(0.3) + d, e, p = data:receive("*a", nil, 100) + assert(e == "oversized" and #p == 100, + "E4 failed: expected oversized not timeout, got " .. tostring(e)) + + reconnect() + remote(string.format([[data:send(string.rep('a',%d))]], 50)) + data:settimeout(0.3) + d, e, p = data:receive("*a", nil, 100) + assert(e == "timeout" and #p < 100, "E5 failed") + pass("ok") + + -- Group F: numeric pattern (recvraw untouched) + reconnect() + printf("numeric pattern (recvraw unchanged): ") + remote(string.format([[data:send(string.rep('a',%d))]], 50)) + d, e = data:receive(50, nil, 100) + assert(d == string.rep("a", 50) and e == nil, "F1 failed") + + reconnect() + remote(string.format([[data:send(string.rep('a',%d))]], 25)) + d, e = data:receive(50, string.rep("p", 25), 100) + assert(e == nil and #d == 50, "F2 failed") + pass("ok") + + -- Group G: no-maxsize regression snapshot + reconnect() + printf("no-maxsize regression: ") + remote(string.format([[data:send(string.rep('a',%d) .. '\n')]], 5000)) + d, e = data:receive("*l") + assert(d == string.rep("a", 5000) and e == nil, "G2 failed: plain *l") + remote(string.format([[data:send(string.rep('a',%d) .. '\n')]], 5000)) + d, e = data:receive("*l", "") + assert(d == string.rep("a", 5000) and e == nil, "G2 failed: *l with prefix") + pass("ok") +end + ------------------------------------------------------------------------ test("method registration") @@ -796,6 +967,9 @@ test_blockingtimeoutreceive(800091, 2, 3) test_blockingtimeoutreceive(800091, 3, 2) test_blockingtimeoutreceive(800091, 3, 1) +test("receive maxsize") +test_maxsize() + test("shutting server down") reconnect() remote("os.exit()") diff --git a/test/utestclnt.lua b/test/utestclnt.lua index 7f10643..395260c 100644 --- a/test/utestclnt.lua +++ b/test/utestclnt.lua @@ -510,6 +510,48 @@ remote(string.format([[ print("ok") end +------------------------------------------------------------------------ +function test_maxsize() + -- A4: #prefix == maxsize raises (mirrors testclnt.lua group A) + reconnect() + pass("argument errors") + -- bounds the pre-implementation call (arg 4 silently dropped, so this + -- becomes a real blocking read on an idle socket) so a meaningful + -- failure doesn't hang the suite + data:settimeout(0.2) + local ok = pcall(data.receive, data, "*l", string.rep("x", 10), 10) + assert(not ok, "A4 failed: #prefix == maxsize should raise") + data:settimeout(-1) + + -- B2/B3: *l boundary (mirrors testclnt.lua group B) + pass("*l boundary") + remote(string.format([[data:send(string.rep('a',%d) .. '\n')]], 100)) + local d, e, p = data:receive("*l", nil, 100) + assert(d == string.rep("a", 100) and e == nil, "B2 failed") + + remote(string.format([[data:send(string.rep('a',%d) .. '\n')]], 101)) + d, e, p = data:receive("*l", nil, 100) + assert(d == nil and e == "oversized" and p == string.rep("a", 100), "B3 failed") + d, e = data:receive("*l", nil, 100) + assert(d == "a" and e == nil, "B3 failed: leftover byte") + + -- C2: timeout exactly at the cap is oversized, not timeout (I1) + reconnect() + remote(string.format([[data:send(string.rep('a',%d))]], 100)) + data:settimeout(0.5) + d, e, p = data:receive("*l", nil, 100) + assert(e == "oversized" and #p == 100, + "C2 failed: expected oversized, got " .. tostring(e)) + + -- E2: completion beats the cap (I2) + reconnect() + remote(string.format([[data:send(string.rep('a',%d)) data:close() data = nil]], 100)) + d, e = data:receive("*a", nil, 100) + assert(d == string.rep("a", 100) and e == nil, + "E2 failed: completion should beat the cap") + pass("ok") +end + ------------------------------------------------------------------------ test("method registration") @@ -641,4 +683,7 @@ test_blockingtimeoutreceive(800091, 2, 3) test_blockingtimeoutreceive(800091, 3, 2) test_blockingtimeoutreceive(800091, 3, 1) +test("receive maxsize") +test_maxsize() + test(string.format("done in %.2fs", socket.gettime() - start)) -- cgit v1.2.3-55-g6feb From 8cbbce56e64c84b53593c815f6dcdd9214897b74 Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Sat, 29 Aug 2026 17:09:47 +0200 Subject: fix(receive): guard maxsize against size_t overflow on cast Casting a double larger than SIZE_MAX to size_t is undefined behavior; bound-check maxsize before the cast. --- src/buffer.c | 3 ++- test/testclnt.lua | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) (limited to 'test') diff --git a/src/buffer.c b/src/buffer.c index 05d6fb3..5f06fc1 100644 --- a/src/buffer.c +++ b/src/buffer.c @@ -134,7 +134,8 @@ int buffer_meth_receive(lua_State *L, p_buffer buf) { } if (!lua_isnoneornil(L, 4)) { double m = luaL_checknumber(L, 4); - luaL_argcheck(L, m >= 1, 4, "maxsize must be a positive number"); + luaL_argcheck(L, m >= 1 && m < (lua_Number) ((size_t) -1), 4, + "maxsize must be a positive number"); maxsize = (size_t) m; luaL_argcheck(L, size < maxsize, 4, "prefix length >= maxsize (drain with prefix=\"\" or raise maxsize)"); diff --git a/test/testclnt.lua b/test/testclnt.lua index 3e897c9..ec154ea 100644 --- a/test/testclnt.lua +++ b/test/testclnt.lua @@ -640,14 +640,16 @@ function test_maxsize() assert(not ok, "A5 failed: #prefix > maxsize should raise") ok = pcall(data.receive, data, 100, nil, 50) assert(not ok, "A6 failed: wanted > maxsize should raise") + ok = pcall(data.receive, data, "*l", nil, math.huge) + assert(not ok, "A7 failed: maxsize=math.huge should raise (size_t overflow)") data:settimeout(0.1) ok = pcall(data.receive, data, 50, nil, 100) - assert(ok, "A7 failed: wanted <= maxsize should not raise") + assert(ok, "A8 failed: wanted <= maxsize should not raise") data:settimeout(-1) remote [[ data:send('intact\n') ]] local line, err = data:receive("*l", nil, 100) assert(line == "intact", - "A8 failed: socket touched by a failed argcheck (err=" .. + "A9 failed: socket touched by a failed argcheck (err=" .. tostring(err) .. ")") pass("ok") -- cgit v1.2.3-55-g6feb From 827ae20771d34912a8f79b50dc68e6430945b9eb Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Sat, 29 Aug 2026 17:19:19 +0200 Subject: fix(receive): guard numeric pattern against size_t overflow on cast Same class of bug as the maxsize cast: a double larger than SIZE_MAX cast to size_t is undefined behavior. Bound-check the numeric receive pattern before the cast, and cover it with a test. --- src/buffer.c | 3 ++- test/testclnt.lua | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) (limited to 'test') diff --git a/src/buffer.c b/src/buffer.c index 5f06fc1..3d48a09 100644 --- a/src/buffer.c +++ b/src/buffer.c @@ -125,7 +125,8 @@ int buffer_meth_receive(lua_State *L, p_buffer buf) { /* ---- validation: must precede timeout_markstart() and any I/O ---- */ if (numeric) { double n = lua_tonumber(L, 2); - luaL_argcheck(L, n >= 0, 2, "invalid receive pattern"); + luaL_argcheck(L, n >= 0 && n < (lua_Number) ((size_t) -1), 2, + "invalid receive pattern"); wanted = (size_t) n; } else { const char *p = luaL_optstring(L, 2, "*l"); diff --git a/test/testclnt.lua b/test/testclnt.lua index ec154ea..ee64d3a 100644 --- a/test/testclnt.lua +++ b/test/testclnt.lua @@ -642,14 +642,16 @@ function test_maxsize() assert(not ok, "A6 failed: wanted > maxsize should raise") ok = pcall(data.receive, data, "*l", nil, math.huge) assert(not ok, "A7 failed: maxsize=math.huge should raise (size_t overflow)") + ok = pcall(data.receive, data, math.huge) + assert(not ok, "A8 failed: wanted=math.huge should raise (size_t overflow)") data:settimeout(0.1) ok = pcall(data.receive, data, 50, nil, 100) - assert(ok, "A8 failed: wanted <= maxsize should not raise") + assert(ok, "A9 failed: wanted <= maxsize should not raise") data:settimeout(-1) remote [[ data:send('intact\n') ]] local line, err = data:receive("*l", nil, 100) assert(line == "intact", - "A9 failed: socket touched by a failed argcheck (err=" .. + "A10 failed: socket touched by a failed argcheck (err=" .. tostring(err) .. ")") pass("ok") -- cgit v1.2.3-55-g6feb From 4863f32b358a8d533f629084a86fb4932ebbd2f3 Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Mon, 31 Aug 2026 08:26:32 +0200 Subject: chore(test): add 2 tests reusing partial results Tests to show the socket isn't left in an undetermined state due to dataloss, since the partial results are returned to the user. --- test/testclnt.lua | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) (limited to 'test') diff --git a/test/testclnt.lua b/test/testclnt.lua index ee64d3a..bbe48bb 100644 --- a/test/testclnt.lua +++ b/test/testclnt.lua @@ -684,6 +684,14 @@ function test_maxsize() assert(d == string.rep("a", 100) and e == nil, "B5 failed: CRLF at boundary") pass("ok") + remote(string.format([[data:send(string.rep('a',%d) .. '\n')]], 101)) + d, e, p = data:receive("*l", nil, 100) + assert(d == nil and e == "oversized" and p == string.rep("a", 100), + "B6 failed: one-over-budget should be oversized") + d, e = data:receive("*l", p, 150) -- increase limit, try again with prefix + assert(d == string.rep("a", 101) and e == nil, + "B6 failed: leftover byte and terminator should still be there") + -- Group C: *l and I1 (timeout/close at the cap) reconnect() printf("I1 (timeout/closed at the cap): ") @@ -748,11 +756,20 @@ function test_maxsize() reconnect() remote(string.format([[data:send(string.rep('a',%d)) data:close() data = nil]], 250)) d, e, p = data:receive("*a", "", 100) - assert(e == "oversized" and #p == 100, "E3 failed: first chunk") + assert(e == "oversized" and #p == 100, "E3a failed: first chunk") d, e, p = data:receive("*a", "", 100) - assert(e == "oversized" and #p == 100, "E3 failed: second chunk") + assert(e == "oversized" and #p == 100, "E3a failed: second chunk") d, e = data:receive("*a", "", 100) - assert(d == string.rep("a", 50) and e == nil, "E3 failed: final chunk") + assert(d == string.rep("a", 50) and e == nil, "E3a failed: final chunk") + + reconnect() + remote(string.format([[data:send(string.rep('a',%d)) data:close() data = nil]], 250)) + d, e, p = data:receive("*a", "", 100) + assert(e == "oversized" and #p == 100, "E3b failed: first chunk") + d, e, p = data:receive("*a", p, 200) -- increase maxsize, try again with prefix + assert(e == "oversized" and #p == 200, "E3b failed: second chunk") + d, e = data:receive("*a", p, 300) -- increase maxsize, try again with prefix + assert(d == string.rep("a", 250) and e == nil, "E3b failed: final chunk") reconnect() remote(string.format([[data:send(string.rep('a',%d))]], 100)) -- cgit v1.2.3-55-g6feb