diff --git a/Makefile b/Makefile index 7c0b584..510facc 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docs/index.html b/docs/index.html index 4c08240..620e075 100644 --- a/docs/index.html +++ b/docs/index.html @@ -100,6 +100,12 @@
copas.setfinalizer
sets a handler on a thread/coroutine that will be executed upon
+ garbage-collecting the coroutine.copas.removethread
would not remove a sleeping thread immediately (it would not execute, but
diff --git a/docs/reference.html b/docs/reference.html
index 843416e..9e1a516 100644
--- a/docs/reference.html
+++ b/docs/reference.html
@@ -287,6 +287,22 @@ func
must be provided).
copas.setfinalizer([coroutine,] handler[, ctx])
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 setfinalizer
is called
+ repeatedly, then all finalizers will be called in reverse order.
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 "[finalizer]"
prefix.
If coroutine
is omitted then it defaults to the currently running coroutine.
The ctx
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 ctx
parameter as argument;
+ function(ctx)
.
copas.setsocketname(name, skt)
Sets the name for the socket.
diff --git a/src/copas.lua b/src/copas.lua index 0c79237..e715ef6 100644 --- a/src/copas.lua +++ b/src/copas.lua @@ -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" } @@ -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`. @@ -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 diff --git a/tests/finalizers.lua b/tests/finalizers.lua new file mode 100644 index 0000000..0446e00 --- /dev/null +++ b/tests/finalizers.lua @@ -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!")