diff options
| author | Thijs Schreijer <thijs@thijsschreijer.nl> | 2026-07-30 19:22:17 +0200 |
|---|---|---|
| committer | Thijs Schreijer <thijs@thijsschreijer.nl> | 2026-07-30 22:06:04 +0200 |
| commit | 73b0780321acb14bab6707006f5136ea9998951d (patch) | |
| tree | 30c5d0cff89913185df7f5fb7ebe1636827cfd58 /test | |
| parent | e13de2013749961edaa126697f3290d3dca91823 (diff) | |
| download | luasocket-73b0780321acb14bab6707006f5136ea9998951d.tar.gz luasocket-73b0780321acb14bab6707006f5136ea9998951d.tar.bz2 luasocket-73b0780321acb14bab6707006f5136ea9998951d.zip | |
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.
Diffstat (limited to 'test')
| -rw-r--r-- | test/testclnt.lua | 174 | ||||
| -rw-r--r-- | test/utestclnt.lua | 45 |
2 files changed, 219 insertions, 0 deletions
diff --git a/test/testclnt.lua b/test/testclnt.lua index 170e187..3e897c9 100644 --- a/test/testclnt.lua +++ b/test/testclnt.lua | |||
| @@ -619,6 +619,177 @@ remote([[ | |||
| 619 | end | 619 | end |
| 620 | 620 | ||
| 621 | ------------------------------------------------------------------------ | 621 | ------------------------------------------------------------------------ |
| 622 | function test_maxsize() | ||
| 623 | -- Group A: argument errors, raised before any I/O | ||
| 624 | reconnect() | ||
| 625 | printf("argument errors: ") | ||
| 626 | -- bounds pre-implementation calls (where arg 4 is silently dropped and | ||
| 627 | -- these become real, otherwise-unbounded blocking reads on an idle | ||
| 628 | -- socket) so a meaningful failure doesn't hang the suite | ||
| 629 | data:settimeout(0.2) | ||
| 630 | local ok | ||
| 631 | ok = pcall(data.receive, data, "*l", nil, 0) | ||
| 632 | assert(not ok, "A1 failed: maxsize=0 should raise") | ||
| 633 | ok = pcall(data.receive, data, "*l", nil, -1) | ||
| 634 | assert(not ok, "A2 failed: maxsize=-1 should raise") | ||
| 635 | ok = pcall(data.receive, data, "*l", nil, "abc") | ||
| 636 | assert(not ok, "A3 failed: non-number maxsize should raise") | ||
| 637 | ok = pcall(data.receive, data, "*l", string.rep("x", 10), 10) | ||
| 638 | assert(not ok, "A4 failed: #prefix == maxsize should raise") | ||
| 639 | ok = pcall(data.receive, data, "*l", string.rep("x", 11), 10) | ||
| 640 | assert(not ok, "A5 failed: #prefix > maxsize should raise") | ||
| 641 | ok = pcall(data.receive, data, 100, nil, 50) | ||
| 642 | assert(not ok, "A6 failed: wanted > maxsize should raise") | ||
| 643 | data:settimeout(0.1) | ||
| 644 | ok = pcall(data.receive, data, 50, nil, 100) | ||
| 645 | assert(ok, "A7 failed: wanted <= maxsize should not raise") | ||
| 646 | data:settimeout(-1) | ||
| 647 | remote [[ data:send('intact\n') ]] | ||
| 648 | local line, err = data:receive("*l", nil, 100) | ||
| 649 | assert(line == "intact", | ||
| 650 | "A8 failed: socket touched by a failed argcheck (err=" .. | ||
| 651 | tostring(err) .. ")") | ||
| 652 | pass("ok") | ||
| 653 | |||
| 654 | -- Group B: *l boundary | ||
| 655 | reconnect() | ||
| 656 | printf("*l boundary: ") | ||
| 657 | remote [[ data:send('hello\n') ]] | ||
| 658 | local d, e, p = data:receive("*l", nil, 100) | ||
| 659 | assert(d == "hello" and e == nil, "B1 failed") | ||
| 660 | |||
| 661 | remote(string.format([[data:send(string.rep('a',%d) .. '\n')]], 100)) | ||
| 662 | d, e = data:receive("*l", nil, 100) | ||
| 663 | assert(d == string.rep("a", 100) and e == nil, | ||
| 664 | "B2 failed: exact-budget line should succeed") | ||
| 665 | |||
| 666 | remote(string.format([[data:send(string.rep('a',%d) .. '\n')]], 101)) | ||
| 667 | d, e, p = data:receive("*l", nil, 100) | ||
| 668 | assert(d == nil and e == "oversized" and p == string.rep("a", 100), | ||
| 669 | "B3 failed: one-over-budget should be oversized") | ||
| 670 | d, e = data:receive("*l", nil, 100) | ||
| 671 | assert(d == "a" and e == nil, | ||
| 672 | "B3 failed: leftover byte and terminator should still be there") | ||
| 673 | |||
| 674 | remote(string.format([[data:send(string.rep('\r',%d) .. string.rep('a',%d) .. '\n')]], 50, 100)) | ||
| 675 | d, e = data:receive("*l", nil, 100) | ||
| 676 | assert(d == string.rep("a", 100) and e == nil, "B4 failed: CRs must not count") | ||
| 677 | |||
| 678 | remote(string.format([[data:send(string.rep('a',%d) .. '\r\n')]], 100)) | ||
| 679 | d, e = data:receive("*l", nil, 100) | ||
| 680 | assert(d == string.rep("a", 100) and e == nil, "B5 failed: CRLF at boundary") | ||
| 681 | pass("ok") | ||
| 682 | |||
| 683 | -- Group C: *l and I1 (timeout/close at the cap) | ||
| 684 | reconnect() | ||
| 685 | printf("I1 (timeout/closed at the cap): ") | ||
| 686 | remote(string.format([[data:send(string.rep('a',%d))]], 50)) | ||
| 687 | data:settimeout(0.5) | ||
| 688 | d, e, p = data:receive("*l", nil, 100) | ||
| 689 | assert(d == nil and e == "timeout", "C1 failed: expected timeout below cap") | ||
| 690 | assert(#p < 100, "C1 failed: timeout partial must be strictly < maxsize") | ||
| 691 | ok = pcall(data.receive, data, "*l", p, 100) | ||
| 692 | assert(ok, "C1 failed: retry with prefix=partial, same maxsize must not raise") | ||
| 693 | |||
| 694 | reconnect() | ||
| 695 | remote(string.format([[data:send(string.rep('a',%d))]], 100)) | ||
| 696 | data:settimeout(0.5) | ||
| 697 | d, e, p = data:receive("*l", nil, 100) | ||
| 698 | assert(e == "oversized" and #p == 100, | ||
| 699 | "C2 failed: exactly-at-cap timeout must be oversized, got " .. tostring(e)) | ||
| 700 | |||
| 701 | reconnect() | ||
| 702 | remote(string.format([[data:send(string.rep('a',%d)) data:close() data = nil]], 100)) | ||
| 703 | d, e, p = data:receive("*l", nil, 100) | ||
| 704 | assert(e == "oversized" and #p == 100, | ||
| 705 | "C3 failed: exactly-at-cap close must be oversized, got " .. tostring(e)) | ||
| 706 | pass("ok") | ||
| 707 | |||
| 708 | -- Group D: drain idiom | ||
| 709 | -- 5030 (not a multiple of the 100 cap) so the boundary of the 50th | ||
| 710 | -- oversized chunk doesn't land exactly on the '\n': when it does, the | ||
| 711 | -- terminator check wins over the cap check (by design -- see B2/I1) and | ||
| 712 | -- the would-be 50th oversized chunk instead succeeds outright. | ||
| 713 | reconnect() | ||
| 714 | printf("drain idiom: ") | ||
| 715 | remote(string.format([[data:send(string.rep('x',%d) .. '\n' .. 'next\n')]], 5030)) | ||
| 716 | local iterations = 0 | ||
| 717 | repeat | ||
| 718 | d, e, p = data:receive("*l", "", 100) | ||
| 719 | if e == "oversized" then | ||
| 720 | assert(#p == 100, "D1 failed: oversized partial length " .. #p) | ||
| 721 | iterations = iterations + 1 | ||
| 722 | end | ||
| 723 | until e ~= "oversized" | ||
| 724 | assert(iterations == 50, | ||
| 725 | "D1 failed: expected 50 oversized iterations, got " .. iterations) | ||
| 726 | assert(d == string.rep("x", 30) and e == nil, "D1 failed: final call should succeed") | ||
| 727 | local nextline = data:receive("*l") | ||
| 728 | assert(nextline == "next", "D1 failed: stream misaligned, got " .. tostring(nextline)) | ||
| 729 | pass("ok") | ||
| 730 | |||
| 731 | -- Group E: *a | ||
| 732 | reconnect() | ||
| 733 | printf("*a boundary: ") | ||
| 734 | remote [[ data:send('abc') data:close() data = nil ]] | ||
| 735 | d, e = data:receive("*a", nil, 100) | ||
| 736 | assert(d == "abc" and e == nil, "E1 failed") | ||
| 737 | |||
| 738 | reconnect() | ||
| 739 | remote(string.format([[data:send(string.rep('a',%d)) data:close() data = nil]], 100)) | ||
| 740 | d, e = data:receive("*a", nil, 100) | ||
| 741 | assert(d == string.rep("a", 100) and e == nil, | ||
| 742 | "E2 failed: completion should beat the cap") | ||
| 743 | |||
| 744 | reconnect() | ||
| 745 | remote(string.format([[data:send(string.rep('a',%d)) data:close() data = nil]], 250)) | ||
| 746 | d, e, p = data:receive("*a", "", 100) | ||
| 747 | assert(e == "oversized" and #p == 100, "E3 failed: first chunk") | ||
| 748 | d, e, p = data:receive("*a", "", 100) | ||
| 749 | assert(e == "oversized" and #p == 100, "E3 failed: second chunk") | ||
| 750 | d, e = data:receive("*a", "", 100) | ||
| 751 | assert(d == string.rep("a", 50) and e == nil, "E3 failed: final chunk") | ||
| 752 | |||
| 753 | reconnect() | ||
| 754 | remote(string.format([[data:send(string.rep('a',%d))]], 100)) | ||
| 755 | data:settimeout(0.3) | ||
| 756 | d, e, p = data:receive("*a", nil, 100) | ||
| 757 | assert(e == "oversized" and #p == 100, | ||
| 758 | "E4 failed: expected oversized not timeout, got " .. tostring(e)) | ||
| 759 | |||
| 760 | reconnect() | ||
| 761 | remote(string.format([[data:send(string.rep('a',%d))]], 50)) | ||
| 762 | data:settimeout(0.3) | ||
| 763 | d, e, p = data:receive("*a", nil, 100) | ||
| 764 | assert(e == "timeout" and #p < 100, "E5 failed") | ||
| 765 | pass("ok") | ||
| 766 | |||
| 767 | -- Group F: numeric pattern (recvraw untouched) | ||
| 768 | reconnect() | ||
| 769 | printf("numeric pattern (recvraw unchanged): ") | ||
| 770 | remote(string.format([[data:send(string.rep('a',%d))]], 50)) | ||
| 771 | d, e = data:receive(50, nil, 100) | ||
| 772 | assert(d == string.rep("a", 50) and e == nil, "F1 failed") | ||
| 773 | |||
| 774 | reconnect() | ||
| 775 | remote(string.format([[data:send(string.rep('a',%d))]], 25)) | ||
| 776 | d, e = data:receive(50, string.rep("p", 25), 100) | ||
| 777 | assert(e == nil and #d == 50, "F2 failed") | ||
| 778 | pass("ok") | ||
| 779 | |||
| 780 | -- Group G: no-maxsize regression snapshot | ||
| 781 | reconnect() | ||
| 782 | printf("no-maxsize regression: ") | ||
| 783 | remote(string.format([[data:send(string.rep('a',%d) .. '\n')]], 5000)) | ||
| 784 | d, e = data:receive("*l") | ||
| 785 | assert(d == string.rep("a", 5000) and e == nil, "G2 failed: plain *l") | ||
| 786 | remote(string.format([[data:send(string.rep('a',%d) .. '\n')]], 5000)) | ||
| 787 | d, e = data:receive("*l", "") | ||
| 788 | assert(d == string.rep("a", 5000) and e == nil, "G2 failed: *l with prefix") | ||
| 789 | pass("ok") | ||
| 790 | end | ||
| 791 | |||
| 792 | ------------------------------------------------------------------------ | ||
| 622 | test("method registration") | 793 | test("method registration") |
| 623 | 794 | ||
| 624 | local tcp_methods = { | 795 | local tcp_methods = { |
| @@ -796,6 +967,9 @@ test_blockingtimeoutreceive(800091, 2, 3) | |||
| 796 | test_blockingtimeoutreceive(800091, 3, 2) | 967 | test_blockingtimeoutreceive(800091, 3, 2) |
| 797 | test_blockingtimeoutreceive(800091, 3, 1) | 968 | test_blockingtimeoutreceive(800091, 3, 1) |
| 798 | 969 | ||
| 970 | test("receive maxsize") | ||
| 971 | test_maxsize() | ||
| 972 | |||
| 799 | test("shutting server down") | 973 | test("shutting server down") |
| 800 | reconnect() | 974 | reconnect() |
| 801 | remote("os.exit()") | 975 | 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 | |||
| @@ -511,6 +511,48 @@ remote(string.format([[ | |||
| 511 | end | 511 | end |
| 512 | 512 | ||
| 513 | ------------------------------------------------------------------------ | 513 | ------------------------------------------------------------------------ |
| 514 | function test_maxsize() | ||
| 515 | -- A4: #prefix == maxsize raises (mirrors testclnt.lua group A) | ||
| 516 | reconnect() | ||
| 517 | pass("argument errors") | ||
| 518 | -- bounds the pre-implementation call (arg 4 silently dropped, so this | ||
| 519 | -- becomes a real blocking read on an idle socket) so a meaningful | ||
| 520 | -- failure doesn't hang the suite | ||
| 521 | data:settimeout(0.2) | ||
| 522 | local ok = pcall(data.receive, data, "*l", string.rep("x", 10), 10) | ||
| 523 | assert(not ok, "A4 failed: #prefix == maxsize should raise") | ||
| 524 | data:settimeout(-1) | ||
| 525 | |||
| 526 | -- B2/B3: *l boundary (mirrors testclnt.lua group B) | ||
| 527 | pass("*l boundary") | ||
| 528 | remote(string.format([[data:send(string.rep('a',%d) .. '\n')]], 100)) | ||
| 529 | local d, e, p = data:receive("*l", nil, 100) | ||
| 530 | assert(d == string.rep("a", 100) and e == nil, "B2 failed") | ||
| 531 | |||
| 532 | remote(string.format([[data:send(string.rep('a',%d) .. '\n')]], 101)) | ||
| 533 | d, e, p = data:receive("*l", nil, 100) | ||
| 534 | assert(d == nil and e == "oversized" and p == string.rep("a", 100), "B3 failed") | ||
| 535 | d, e = data:receive("*l", nil, 100) | ||
| 536 | assert(d == "a" and e == nil, "B3 failed: leftover byte") | ||
| 537 | |||
| 538 | -- C2: timeout exactly at the cap is oversized, not timeout (I1) | ||
| 539 | reconnect() | ||
| 540 | remote(string.format([[data:send(string.rep('a',%d))]], 100)) | ||
| 541 | data:settimeout(0.5) | ||
| 542 | d, e, p = data:receive("*l", nil, 100) | ||
| 543 | assert(e == "oversized" and #p == 100, | ||
| 544 | "C2 failed: expected oversized, got " .. tostring(e)) | ||
| 545 | |||
| 546 | -- E2: completion beats the cap (I2) | ||
| 547 | reconnect() | ||
| 548 | remote(string.format([[data:send(string.rep('a',%d)) data:close() data = nil]], 100)) | ||
| 549 | d, e = data:receive("*a", nil, 100) | ||
| 550 | assert(d == string.rep("a", 100) and e == nil, | ||
| 551 | "E2 failed: completion should beat the cap") | ||
| 552 | pass("ok") | ||
| 553 | end | ||
| 554 | |||
| 555 | ------------------------------------------------------------------------ | ||
| 514 | 556 | ||
| 515 | test("method registration") | 557 | test("method registration") |
| 516 | test_methods(socket.unix(), { | 558 | test_methods(socket.unix(), { |
| @@ -641,4 +683,7 @@ test_blockingtimeoutreceive(800091, 2, 3) | |||
| 641 | test_blockingtimeoutreceive(800091, 3, 2) | 683 | test_blockingtimeoutreceive(800091, 3, 2) |
| 642 | test_blockingtimeoutreceive(800091, 3, 1) | 684 | test_blockingtimeoutreceive(800091, 3, 1) |
| 643 | 685 | ||
| 686 | test("receive maxsize") | ||
| 687 | test_maxsize() | ||
| 688 | |||
| 644 | test(string.format("done in %.2fs", socket.gettime() - start)) | 689 | test(string.format("done in %.2fs", socket.gettime() - start)) |
