From 850e6dddfa4cc3014c603b0f9dd1ea11f23635d7 Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Tue, 7 Apr 2026 22:08:10 +0200 Subject: [PATCH 1/4] feat(cancelall): new method to cancel all pending work and exit --- src/copas.lua | 55 +++++++++++++++++++++++++++++++++++++++++ tests/cancelall.lua | 60 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 tests/cancelall.lua diff --git a/src/copas.lua b/src/copas.lua index fc1442e..b2d3286 100644 --- a/src/copas.lua +++ b/src/copas.lua @@ -312,6 +312,12 @@ local _sleeping = {} do heap:remove(co) end + function _sleeping:cancelall(protected_co) + while heap:size() > 0 do heap:pop() end + heap:insert(gettime() + TIMEOUT_PRECISION, protected_co) + -- lethargy is weak; copas's idle GC sweeps will clean it within a few steps + end + -- @param tos number of timeouts running function _sleeping:done(tos) -- return true if we have nothing more to do @@ -1374,6 +1380,8 @@ end -- Timeout management ------------------------------------------------------------------------------- +local _get_timeout_thread -- forward declaration; assigned in the block below + do local timeout_register = setmetatable({}, { __mode = "k" }) local time_out_thread @@ -1393,6 +1401,8 @@ do end end) + _get_timeout_thread = function() return time_out_thread end + -- get the number of timeouts running function copas.gettimeouts() return timerwheel:count() @@ -1706,6 +1716,51 @@ local resetexit do end +--- Forcibly cancels all pending work and signals exit. +-- Intended for test teardown only. Abandons all registered threads and sockets +-- without giving them a chance to clean up. After this call copas.finished() +-- will return true and the loop will exit. The module is left in a clean state +-- ready for the next copas.loop() call. +function copas.cancelall() + -- 1. clear resumable queue + _resumable:clear_resumelist() + + -- 2. drain sleeping heap, re-insert internal timeout-timer so done() stays valid + _sleeping:cancelall(_get_timeout_thread()) + + -- 3. close and drain reading sockets (snapshot to avoid mutate-while-iterate) + local socks = {} + for i = 1, #_reading do socks[i] = _reading[i] end + for _, skt in ipairs(socks) do + pcall(skt.close, skt) + _reading:remove(skt) + end + + -- 4. close and drain writing sockets + socks = {} + for i = 1, #_writing do socks[i] = _writing[i] end + for _, skt in ipairs(socks) do + pcall(skt.close, skt) + _writing:remove(skt) + end + + -- 5. remove all servers + socks = {} + for i = 1, #_servers do socks[i] = _servers[i] end + for _, skt in ipairs(socks) do + copas.removeserver(skt) + end + + -- 6. clear non-weak ancillary tables + _closed = {} + _reading_log = {} + _writing_log = {} + + -- 7. signal exit + copas.exit() +end + + local _getstats do local _getstats_instrumented, _getstats_plain diff --git a/tests/cancelall.lua b/tests/cancelall.lua new file mode 100644 index 0000000..47a07a3 --- /dev/null +++ b/tests/cancelall.lua @@ -0,0 +1,60 @@ +-- make sure we are pointing to the local copas first +package.path = string.format("../src/?.lua;%s", package.path) + +local copas = require "copas" +local socket = require "socket" +local timer = copas.timer + +-- Test 1: cancelall() exits a loop that has a server, a sleeping thread, and a +-- thread blocked on a socket. + +local loop1_exited = false + +copas.loop(function() + -- register a server that will never get a connection + local srv = socket.tcp() + assert(srv:bind("127.0.0.1", 0)) + srv:listen(5) + copas.addserver(srv, function() end) + + -- register a thread that sleeps forever + copas.addthread(function() + copas.sleep(math.huge) + end) + + -- register a thread that waits on a socket read (connect to our own server) + local port = select(2, srv:getsockname()) + copas.addthread(function() + local client = copas.wrap(socket.tcp()) + client:connect("127.0.0.1", port) + client:receive("*l") -- blocks waiting for data that never arrives + end) + + -- fire cancelall after a short delay + timer.new({ + delay = 0.1, + callback = function() + copas.cancelall() + end, + }) +end) + +loop1_exited = true +print("loop 1 exited ok") + +-- Test 2: after cancelall(), a fresh copas.loop() works correctly. + +local loop2_done = false + +copas.loop(function() + copas.addthread(function() + copas.sleep(0.05) + loop2_done = true + end) +end) + +assert(loop2_done, "loop 2 did not complete its task") +print("loop 2 exited ok") + +assert(loop1_exited) +print("all tests passed") From e6ff9ca96a7b00222a914a1e18ec9771d59bb268 Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Wed, 8 Apr 2026 08:55:29 +0200 Subject: [PATCH 2/4] apply some fixes --- src/copas.lua | 16 ++++++---------- tests/cancelall.lua | 26 ++++++++++++++++---------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/copas.lua b/src/copas.lua index b2d3286..cca864d 100644 --- a/src/copas.lua +++ b/src/copas.lua @@ -43,6 +43,7 @@ local gettime = (socket or system).gettime local block_sleep = (socket or system).sleep local ssl -- only loaded upon demand +local core_timer_thread local WATCH_DOG_TIMEOUT = 120 local UDP_DATAGRAM_MAX = (socket or {})._DATAGRAMSIZE or 8192 local TIMEOUT_PRECISION = 0.1 -- 100ms @@ -312,9 +313,9 @@ local _sleeping = {} do heap:remove(co) end - function _sleeping:cancelall(protected_co) + function _sleeping:cancelall() while heap:size() > 0 do heap:pop() end - heap:insert(gettime() + TIMEOUT_PRECISION, protected_co) + heap:insert(gettime() + TIMEOUT_PRECISION, core_timer_thread) -- lethargy is weak; copas's idle GC sweeps will clean it within a few steps end @@ -1380,29 +1381,24 @@ end -- Timeout management ------------------------------------------------------------------------------- -local _get_timeout_thread -- forward declaration; assigned in the block below - do local timeout_register = setmetatable({}, { __mode = "k" }) - local time_out_thread local timerwheel = require("timerwheel").new({ now = gettime, precision = TIMEOUT_PRECISION, ringsize = math.floor(60*60*24/TIMEOUT_PRECISION), -- ring size 1 day err_handler = function(err) - return _deferror(err, time_out_thread) + return _deferror(err, core_timer_thread) end, }) - time_out_thread = copas.addnamedthread("copas_core_timer", function() + core_timer_thread = copas.addnamedthread("copas_core_timer", function() while true do copas.pause(TIMEOUT_PRECISION) timerwheel:step() end end) - _get_timeout_thread = function() return time_out_thread end - -- get the number of timeouts running function copas.gettimeouts() return timerwheel:count() @@ -1726,7 +1722,7 @@ function copas.cancelall() _resumable:clear_resumelist() -- 2. drain sleeping heap, re-insert internal timeout-timer so done() stays valid - _sleeping:cancelall(_get_timeout_thread()) + _sleeping:cancelall() -- 3. close and drain reading sockets (snapshot to avoid mutate-while-iterate) local socks = {} diff --git a/tests/cancelall.lua b/tests/cancelall.lua index 47a07a3..04f52b4 100644 --- a/tests/cancelall.lua +++ b/tests/cancelall.lua @@ -8,22 +8,21 @@ local timer = copas.timer -- Test 1: cancelall() exits a loop that has a server, a sleeping thread, and a -- thread blocked on a socket. -local loop1_exited = false - copas.loop(function() -- register a server that will never get a connection local srv = socket.tcp() - assert(srv:bind("127.0.0.1", 0)) - srv:listen(5) - copas.addserver(srv, function() end) + assert(srv:bind("127.0.0.1", 0, 5)) + copas.addserver(srv, function() + copas.pause(60*60) -- don't handle incoming connections, let them wait + end) -- register a thread that sleeps forever copas.addthread(function() - copas.sleep(math.huge) + copas.pause(60*60) end) -- register a thread that waits on a socket read (connect to our own server) - local port = select(2, srv:getsockname()) + local _, port = srv:getsockname() copas.addthread(function() local client = copas.wrap(socket.tcp()) client:connect("127.0.0.1", port) @@ -32,14 +31,22 @@ copas.loop(function() -- fire cancelall after a short delay timer.new({ - delay = 0.1, + delay = 0.5, callback = function() copas.cancelall() end, }) + + -- set up a timeout timer to make sure the loop exits within a reasonable time + timer.new({ + delay = 5, + callback = function() + print("loop 1 did not exit within 5 seconds") + os.exit(1) -- exit with error code to indicate failure + end, + }) end) -loop1_exited = true print("loop 1 exited ok") -- Test 2: after cancelall(), a fresh copas.loop() works correctly. @@ -56,5 +63,4 @@ end) assert(loop2_done, "loop 2 did not complete its task") print("loop 2 exited ok") -assert(loop1_exited) print("all tests passed") From be49b2feacb266abdb79cbbef95784170834aac3 Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Wed, 8 Apr 2026 09:17:55 +0200 Subject: [PATCH 3/4] apply some more fixes --- src/copas.lua | 26 ++++++++++---------------- tests/cancelall.lua | 2 +- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/copas.lua b/src/copas.lua index cca864d..9f95e01 100644 --- a/src/copas.lua +++ b/src/copas.lua @@ -1721,30 +1721,24 @@ function copas.cancelall() -- 1. clear resumable queue _resumable:clear_resumelist() - -- 2. drain sleeping heap, re-insert internal timeout-timer so done() stays valid + -- 2. drain sleeping heap _sleeping:cancelall() - -- 3. close and drain reading sockets (snapshot to avoid mutate-while-iterate) - local socks = {} - for i = 1, #_reading do socks[i] = _reading[i] end - for _, skt in ipairs(socks) do - pcall(skt.close, skt) - _reading:remove(skt) + -- 3. close and drain reading sockets + while _reading[1] do + copas.close(_reading[1]) + _reading:remove(_reading[1]) end -- 4. close and drain writing sockets - socks = {} - for i = 1, #_writing do socks[i] = _writing[i] end - for _, skt in ipairs(socks) do - pcall(skt.close, skt) - _writing:remove(skt) + while _writing[1] do + copas.close(_writing[1]) + _writing:remove(_writing[1]) end -- 5. remove all servers - socks = {} - for i = 1, #_servers do socks[i] = _servers[i] end - for _, skt in ipairs(socks) do - copas.removeserver(skt) + while _servers[1] do + copas.removeserver(_servers[1]) end -- 6. clear non-weak ancillary tables diff --git a/tests/cancelall.lua b/tests/cancelall.lua index 04f52b4..c531017 100644 --- a/tests/cancelall.lua +++ b/tests/cancelall.lua @@ -55,7 +55,7 @@ local loop2_done = false copas.loop(function() copas.addthread(function() - copas.sleep(0.05) + copas.pause(0.05) loop2_done = true end) end) From 4ba24925e0b6151f1c943300d771a3c90cfc4abe Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Wed, 8 Apr 2026 09:52:19 +0200 Subject: [PATCH 4/4] add documentation and changelog --- docs/index.html | 6 ++++++ docs/reference.html | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/docs/index.html b/docs/index.html index e4a29e2..6b15268 100644 --- a/docs/index.html +++ b/docs/index.html @@ -100,6 +100,12 @@

History

+
Copas 4.x.x [unreleased]
+
    +
  • Feat: added copas.cancelall(). It cancels pending work to force exit the loop. + Intended use is for testing, to force a loop exit, even if a test fails.
  • +
+
Copas 4.10.0 [27/Mar/2026]
  • Feat: added copas.future class. Wraps a thread in a future, allowing other diff --git a/docs/reference.html b/docs/reference.html index 52ae18b..7f970f8 100644 --- a/docs/reference.html +++ b/docs/reference.html @@ -1062,6 +1062,35 @@

    Copas debugging functions

    +
    copas.cancelall()
    +
    +

    This will clear pending work (client sockets, server sockets, timers) to force the copas + loop to exit immediately. This is useful for testing and debugging. +

    + +

    Here's an example which, upon an error, cancels all pending work to ensure the test + ends immediately instead of waiting for the long sleep to end: +

    +
    +copas(function())
    +  copas.seterrorhandler(function(err)
    +    print(err)
    +    copas.cancelall()  -- cancel all pending work to force exit
    +  end, true)           -- true -> set default errorhandler
    +
    +  copas.addthread(function()
    +    print("sleeping long, would block exit")
    +    copas.pause(3600) -- one hour
    +  end)
    +
    +  copas.addthread(function()
    +    copas.pause(1)
    +    error("something went wrong")
    +  end)
    +end)
    +        
    +
    +
    copas.debug.start([logger] [, core])

    This will internally replace coroutine handler functions to provide log output to