Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ <h2><a name="history"></a>History</h2>

<dl class="history">

<dt><strong>Copas 4.x.x</strong> [unreleased]</dt>
<dd><ul>
<li>Feat: added <code>copas.cancelall()</code>. It cancels pending work to force exit the loop.
Intended use is for testing, to force a loop exit, even if a test fails.</li>
</ul></dd>

<dt><strong>Copas 4.10.0</strong> [27/Mar/2026]</dt>
<dd><ul>
<li>Feat: added <code>copas.future</code> class. Wraps a thread in a future, allowing other
Expand Down
29 changes: 29 additions & 0 deletions docs/reference.html
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,35 @@ <h3>Copas debugging functions</h3>

<dl class="reference">

<dt><strong><code>copas.cancelall()</code></strong></dt>
<dd>
<p>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.
</p>

<p>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:
</p>
<pre class="example">
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)
</pre>
</dd>

<dt><strong><code>copas.debug.start([logger] [, core])</code></strong></dt>
<dd>
<p>This will internally replace coroutine handler functions to provide log output to
Expand Down
51 changes: 48 additions & 3 deletions src/copas.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -312,6 +313,12 @@ local _sleeping = {} do
heap:remove(co)
end

function _sleeping:cancelall()
while heap:size() > 0 do heap:pop() end
heap:insert(gettime() + TIMEOUT_PRECISION, core_timer_thread)
-- 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
Expand Down Expand Up @@ -1376,17 +1383,16 @@ end

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()
Expand Down Expand Up @@ -1706,6 +1712,45 @@ 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
_sleeping:cancelall()

-- 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
while _writing[1] do
copas.close(_writing[1])
_writing:remove(_writing[1])
end

-- 5. remove all servers
while _servers[1] do
copas.removeserver(_servers[1])
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

Expand Down
66 changes: 66 additions & 0 deletions tests/cancelall.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
-- 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.

copas.loop(function()
-- register a server that will never get a connection
local srv = socket.tcp()
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.pause(60*60)
end)

-- register a thread that waits on a socket read (connect to our own server)
local _, port = 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.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)

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.pause(0.05)
loop2_done = true
end)
end)

assert(loop2_done, "loop 2 did not complete its task")
print("loop 2 exited ok")

print("all tests passed")
Loading