Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(finalizers): adds finalizers to coroutines #170

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ test: certs
$(LUA) $(DELIM) $(PKGPATH) tests/errhandlers.lua
$(LUA) $(DELIM) $(PKGPATH) tests/exit.lua
$(LUA) $(DELIM) $(PKGPATH) tests/exittest.lua
$(LUA) $(DELIM) $(PKGPATH) tests/finalizers.lua
$(LUA) $(DELIM) $(PKGPATH) tests/http-timeout.lua
$(LUA) $(DELIM) $(PKGPATH) tests/httpredirect.lua
$(LUA) $(DELIM) $(PKGPATH) tests/largetransfer.lua
Expand Down
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="dependencies"></a>Dependencies</h2>
<h2><a name="history"></a>History</h2>

<dl class="history">
<dt><strong>Copas 4.x.x</strong> [unreleased]</dt>
<dd><ul>
<li>Added: <code>copas.setfinalizer</code> sets a handler on a thread/coroutine that will be executed upon
garbage-collecting the coroutine.</li>
</ul></dd>

<dt><strong>Copas 4.7.1</strong> [9/Mar/2024]</dt>
<dd><ul>
<li>Fix: <code>copas.removethread</code> would not remove a sleeping thread immediately (it would not execute, but
Expand Down
16 changes: 16 additions & 0 deletions docs/reference.html
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,22 @@ <h3>Copas dispatcher main functions</h3>
that do not have their own set (in this case <code>func</code> must be provided).</p>
</dd>

<dt><strong><code>copas.setfinalizer([coroutine,] handler[, ctx])</code></strong></dt>
<dd>
<p>Sets a finalizer function for the coroutine. The finalizer is tied to the garbage collector, so when
the coroutine goes out of scope and is collected, the finalizer will run. If <code>setfinalizer</code> is called
repeatedly, then all finalizers will be called in reverse order.</p>
<p>Execution will happen in a new coroutine, the name of the new coroutine will be set to the name of the
original coroutine (at the time of setting the finalizer!) with a <code>"[finalizer]"</code> prefix.</p>

<p>If <code>coroutine</code> is omitted then it defaults to the currently running coroutine.</p>

<p>The <code>ctx</code> parameter is an arbitrary value that will be passed to the finalizer function
to optionally pass some context if desired.
The handler will receive the <code>ctx</code> parameter as argument;
<code>function(ctx)</code>.</p>
</dd>

<dt><strong><code>copas.setsocketname(name, skt)</code></strong></dt>
<dd>
<p>Sets the name for the socket.</p>
Expand Down
115 changes: 113 additions & 2 deletions src/copas.lua
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,53 @@ do
end


-- enables __gc behaviour for Lua < 5.2 using a proxy.
-- Note: Any existing meta table on the table will be replaced, and in case of non-native
-- support for __gc a key `__gc_proxy` will be added to the table
-- @tparam table t the table to provide with an __gc method
-- @tparam function __gc the __gc method to call
local setGCForTable

-- disables any __gc method previously set on a table
-- @tparam table t the table to remove the __gc method from
local clearGCForTable
do
-- test __gc support for the current Lua engine
local supportsGCForTables = false
setmetatable({}, {
__gc = function() supportsGCForTables = true end
})
collectgarbage()
collectgarbage() -- Called twice to ensure collection occurs

if supportsGCForTables then
-- we have native __gc support
function setGCForTable(t, __gc)
return setmetatable(t, {__gc = __gc})
end
function clearGCForTable(t)
(getmetatable(t) or {}).__gc = nil
end
else
-- we have no native __gc support, implement using `newproxy` userdata
function setGCForTable(t, __gc)
-- Use proxy userdata for Lua versions without direct table __gc support
local proxy = newproxy(true)
getmetatable(proxy).__gc = function()
__gc(t)
end
-- Anchor proxy within the table to ensure it's not collected prematurely
t.__gc_proxy = proxy
return t
end
function clearGCForTable(t)
local proxy = t.__gc_proxy or {}
(getmetatable(proxy) or {}).__gc = nil
end
end
end


-- Setup the Copas meta table to auto-load submodules and define a default method
local copas do
local submodules = { "ftp", "http", "lock", "queue", "semaphore", "smtp", "timer" }
Expand Down Expand Up @@ -1120,6 +1167,56 @@ function copas.geterrorhandler(co)
end


do
local finalizers = setmetatable({}, { __mode = "k" }) -- finalizer per coroutine

-- generic finalizer to execute a chain of finalizers
local function generic_finalizer(fin_data)
copas.setthreadname("[finalizer]"..fin_data.taskname)
fin_data.finalizer(fin_data.ctx)
if fin_data.next then
return generic_finalizer(fin_data.next)
end
end

-- sets a finalizer for a coroutine. The finalizer is a function that is called
-- when the coroutine is GC'ed.
-- @tparam[opt] thread co the coroutine to set the finalizer for
-- @tparam function finalizer the finalizer function; func(ctx)
-- @tparam[opt] any ctx a handle of any type to be passed to the finalizer
function copas.setfinalizer(co, finalizer, ctx)
if type(co) == "function" then
-- only 2 args, no co provided, shift args
co, finalizer, ctx = nil, co, finalizer
end
if co == nil then
co = coroutine_running()
end
assert(type(co) == "thread", "Expected the coroutine to be a thread type")
assert(type(finalizer) == "function", "Expected the finalizer to be a function")

local previous_fin_data = finalizers[co]
if previous_fin_data then
clearGCForTable(previous_fin_data) -- remove __gc handler from the old one
end

local fin_data = {
finalizer = finalizer,
ctx = ctx,
taskname = copas.getthreadname(co),
next = previous_fin_data,
}
setGCForTable(fin_data, function(self)
clearGCForTable(fin_data)
-- we schedule a task, since the GC might be called from anywhere (in a pre-emptive manner)
copas.addthread(generic_finalizer, self)
end)
finalizers[co] = fin_data
return true
end
end


-- if `bool` is truthy, then the original socket errors will be returned in case of timeouts;
-- `timeout, wantread, wantwrite, Operation already in progress`. If falsy, it will always
-- return `timeout`.
Expand Down Expand Up @@ -1636,10 +1733,24 @@ end
-- returns false if there are no sockets for read/write nor tasks scheduled
-- (which means Copas is in an empty spin)
-------------------------------------------------------------------------------
function copas.finished()
return #_reading == 0 and #_writing == 0 and _resumable:done() and _sleeping:done(copas.gettimeouts())
do
local function finished()
return #_reading == 0 and #_writing == 0 and _resumable:done() and _sleeping:done(copas.gettimeouts())
end

function copas.finished()
if not finished() then
return false
end

-- we seem done, but ensure to run finalizers and check again if we're done
collectgarbage()
collectgarbage()
return finished()
end
end


local _getstats do
local _getstats_instrumented, _getstats_plain

Expand Down
110 changes: 110 additions & 0 deletions tests/finalizers.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
--- Test for finalizers

local copas = require("copas")

local function check_table(expected, result)
local l = 0
for _, entry in ipairs(result) do
l = math.max(l, #tostring(entry))
end

local passed = true
for i = 1, math.max(#result, #expected) do
if result[i] ~= expected[i] then
for n = 1, math.max(#result, #expected) do
local res = tostring(result[n]) .. string.rep(" ", l + 2)
print(n, res:sub(1,l+2), expected[n], result[n] == expected[n] and "" or " <--- failed")
end
passed = false
break
end
end
return passed
end

-- GC tester
local supported = false
setmetatable({}, {__gc = function(self) supported = true end})
collectgarbage()
collectgarbage()
print("GC supported:", supported)



-- Run multiple finalizers in the right order.
-- Include the proper task names
local ctx = {}
copas(function()
copas.addnamedthread("finalizer-test", function()
-- add finalizer
copas.setfinalizer(function(myctx, coro)
print("finalizer1 runs")
table.insert(myctx, "finalizer 1 called: " .. copas.getthreadname())
end, ctx)

-- add another finalizer, to be called in reverse order
copas.setfinalizer(function(myctx, coro)
print("finalizer2 runs")
table.insert(myctx, "finalizer 2 called: " .. copas.getthreadname())
end, ctx)

copas.setthreadname("test task")
copas.setfinalizer(function(myctx, coro)
print("finalizer3 runs")
table.insert(myctx, "finalizer 3 called: " .. copas.getthreadname())
end, ctx)

table.insert(ctx, "task starting")
copas.pause(1)
table.insert(ctx, "task finished")
end)
end)

assert(check_table({
"task starting",
"task finished",
"finalizer 3 called: [finalizer]test task",
"finalizer 2 called: [finalizer]finalizer-test",
"finalizer 1 called: [finalizer]finalizer-test",
}, ctx), "test failed!")

print("test 1 success!")


-- run finalizer on a task that errors
local ctx = {}
copas(function()
copas.addnamedthread("finalizer-test", function()
-- add finalizer
copas.setfinalizer(function(myctx, coro)
print("finalizer1 runs")
table.insert(myctx, "finalizer 1 called: " .. copas.getthreadname())
end, ctx)

-- add another finalizer, to be called in reverse order
copas.setfinalizer(function(myctx, coro)
print("finalizer2 runs")
table.insert(myctx, "finalizer 2 called: " .. copas.getthreadname())
end, ctx)

copas.setthreadname("test task")
copas.setfinalizer(function(myctx, coro)
print("finalizer3 runs")
table.insert(myctx, "finalizer 3 called: " .. copas.getthreadname())
end, ctx)

table.insert(ctx, "task starting")
copas.pause(1)
error("ooooops... (this error is on purpose!)") --> here we throw an error so we have no normal exit
table.insert(ctx, "task finished")
end)
end)

assert(check_table({
"task starting",
"finalizer 3 called: [finalizer]test task",
"finalizer 2 called: [finalizer]finalizer-test",
"finalizer 1 called: [finalizer]finalizer-test",
}, ctx), "test failed!")

print("test 2 success!")
Loading