A Lua stack-based closer and callback caller, equivalent to Python's
ExitStack,
but for Lua to-be-closed values.
Requires Lua 5.4 or later (which introduced to-be-closed variables).
local close_stack = require('close-stack')A close stack collects to-be-closed values and closes them in reverse order
(LIFO) when the stack itself is closed. The close stack is itself a to-be-closed
value, so you can assign it to a <close> variable and let scope exit handle
cleanup automatically.
local s <close> = close_stack.new()
local file = s:push(io.open('/tmp/example.txt', 'w'))
local conn = s:push(db.connect())
-- use file and conn...
-- both are closed automatically when s goes out of scope,
-- conn first, then file.You can close the stack explicitly instead of relying on scope exit. Even when
closing manually, the stack should still be a <close> variable so that
resources are cleaned up on error or early return. A second close is a no-op —
the stack empties itself on the first close.
local s <close> = close_stack.new()
s:push(resource_a)
s:push(resource_b)
-- close everything now
s:close()
-- safe to call again (including the implicit close on scope exit), does nothing
s:close()callback registers a plain function (with optional arguments) to be called in
its place in the closing order. Unlike closeables, callbacks do not receive the
error value.
local s <close> = close_stack.new()
s:push(some_resource)
s:callback(print, 'cleaning up...')
s:push(another_resource)
-- on scope exit: another_resource closed, then print called, then some_resource closed.pop_all moves all entries to a new close stack and returns it, leaving the
original empty. This is useful for committing resources — do your setup in one
stack, and if everything succeeds, transfer ownership elsewhere.
local function setup()
local s <close> = close_stack.new()
local conn = s:push(db.connect())
local stmt = s:push(conn:prepare('...'))
-- everything succeeded; transfer cleanup responsibility to the caller
return s:pop_all()
end
-- the returned stack owns conn and stmt now
local resources <close> = setup()If setup throws before reaching pop_all, the <close> on s ensures
everything is cleaned up. Once pop_all is called, s is empty, so its scope
exit is a no-op.
In addition to the ordered stack, every close stack keeps a keyed map: a collection of closeables keyed by arbitrary keys. This is handy when you want to register a closeable under a key you can look up later, rather than pushing it onto the ordered stack.
The most convenient way to use it is the public map field, a table-like proxy
you can index, assign, #, and pairs over just like a plain table:
local s <close> = close_stack.new()
s.map.database = db.connect()
s.map[socket_fd] = wrap_socket(socket_fd)
-- reach back in and use them by key
s.map.database:query('...')
for key, closeable in pairs(s.map) do
-- ...
end
-- everything in the map is closed when s is closed.The proxy is a thin view over four methods, which you can also call directly if
you prefer: map_set, map_get, map_pairs, and map_len. s.map.k = v is
s:map_set('k', v), s.map.k is s:map_get('k'), #s.map is s:map_len(),
and pairs(s.map) is s:map_pairs().
s:map_set('database', db.connect())
s:map_get('database'):query('...')Map entries are const once set, mirroring the <close> values they become.
Just as the ordered stack won't let you pull an individual item back out, the map
won't let you reset or unset a key that already holds a real closeable — that
would let you silently drop an owned resource. Assigning (or calling map_set)
over such a key raises an error:
s.map.database = db.connect()
s.map.database = other -- error: can not overwrite an existing map entry
s.map.database = nil -- error: same — you can not unset it eitherA nil (or false) value is not a real resource, so it is treated as "nothing
to store": assigning nil to an empty key is a harmless no-op, and a key that
only holds false may be freely overwritten (nothing is lost). false values
are stored as-is and show up in iteration, consistent with Lua ignoring
nil/false as to-be-closed values.
Because the proxy routes everything through those methods, it inherits all of
this behavior — there is no back door. It only supports data access (index,
assign, #, pairs); the map_* methods live on the close stack itself, not on
the proxy.
Because entries are const, a map_set is really just an append: the closeable is
pushed onto the same ordered stack as push, and the map is only a keyed index
for looking it up later. So map entries close in the same LIFO order as
everything else, at the point where they were set — a push, a map_set, and
another push all close last-in-first-out, interleaved. There is no separate
"map phase"; the two APIs are just two ways to add to one close order (and a
truthy map_set therefore counts toward #stack).
Map values participate in error propagation exactly like pushed values: they
receive the in-flight error in their __close metamethod, and an error thrown by
a map closer replaces the propagating error for everything closed afterward.
pop_all transfers the map along with the stack, and the map is emptied on
close.
When a close stack is used as a <close> variable, it receives the in-flight
error object (if any) and passes it through to every closer in the stack. This
means closers can make decisions based on whether the scope exited normally or
due to an error.
local transaction_metatable = {}
function transaction_metatable:__close(err)
if err then
self.conn:rollback()
else
self.conn:commit()
end
end
local function transaction(connection)
connection:execute('BEGIN')
return setmetatable({conn = connection}, transaction_metatable)
end
local s <close> = close_stack.new()
local conn = s:push(db.connect())
s:push(transaction(conn))
conn:execute('INSERT INTO ...')
-- on normal exit: commit, then close conn
-- on error: rollback, then close connYou can also pass an error explicitly to close():
local s <close> = close_stack.new()
s:push(resource)
s:close(err) -- closers receive err; it is re-raised after all closers runIn this particular case, the effect is the same as just raising the error.
Creates and returns a new close stack.
local s <close> = close_stack.new()Pushes a to-be-closed value onto the stack. Returns the value, so you can push and assign in one expression.
nil and false are ignored (consistent with Lua's treatment of to-be-closed
variables) but still returned, which makes it safe to push values that may be
nil.
local f = s:push(io.open(path)) -- f may be nil if open failedIt is your responsibility to ensure that you only pass a proper closeable value into the stack. A closer that throws an error is fine, but a non-closeable value will throw an error at close-time and make the stack leak its contents.
Registers a function to be called when the stack is closed. Any additional
arguments are stored and passed to fn on close. Returns fn.
Callbacks do not receive the error object. If you need error-aware cleanup, use
push with a value that has a __close metamethod.
s:callback(os.remove, tempfile)A public table-like proxy over the keyed map. Index, assign, #, and pairs
all route through the four map_* methods below:
| proxy expression | equivalent method call |
|---|---|
stack.map[k] |
stack:map_get(k) |
stack.map[k] = v |
stack:map_set(k, v) |
#stack.map |
stack:map_len() |
pairs(stack.map) |
stack:map_pairs() |
Because it delegates, it inherits the const-once-set behavior — there is no way
to overwrite or unset an owned closeable through the proxy either. It supports
only data access; the methods themselves are not reachable through it (e.g.
stack.map.map_get just reads the key 'map_get').
stack.map.log = open_logfile(path)
for key, closeable in pairs(stack.map) do endStores a closeable in the keyed map under key and returns it. A map_set is an
append: the closeable is pushed onto the ordered stack (so it closes in the
shared LIFO order and counts toward #stack), and the map indexes it by key for
lookup. The map is emptied on close and transferred by pop_all.
Entries are const once set: if key already holds a real (truthy) closeable,
map_set raises an error rather than overwriting or unsetting it. A nil or
false value is treated as "nothing to store" — a no-op push, allowed to
overwrite a key that only holds a falsy value. false is stored in the index
as-is (and appears in map_pairs). A nil key raises an error.
stack:map_set('log', open_logfile(path))Returns the closeable stored under key, or nil if there is none.
Returns a stateful iterator yielding (key, closeable) pairs, for use in a
generic for loop. The underlying table is not exposed, so the map can not be
mutated through the iterator.
for key, closeable in stack:map_pairs() do
-- ...
endReturns # of the underlying map table. As with any Lua table, this is only a
meaningful count for sequence (1..n integer) keys; for arbitrary keys, inspect
the map the way you would any plain table (e.g. via map_pairs).
Closes the stack immediately, invoking all closers in reverse order. If err is
non-nil, it is re-raised after all closers have run, and every closer receives
it as the error argument to __close.
After closing, the stack is empty. Calling close() again is a no-op (unless
new items have been pushed since).
Transfers all entries — both the ordered stack and the keyed map — to a new
close stack and returns it. The original stack becomes empty. The returned stack
is a full close stack — it can be used as a <close> variable, closed manually,
or have more items pushed onto it.
Returns the number of entries currently in the ordered stack. Since map_set
appends to that stack, truthy map entries are included in this count. To inspect
the keyed map's own index specifically, use stack:map_len() or
stack:map_pairs().
The close stack itself implements __close. When used as a <close> variable,
it receives the error object from Lua's scope-exit machinery and forwards it to
all closers.
local s <close> = close_stack.new()Direct field assignment on a close stack raises an error. Use the methods above.
If a close stack is garbage collected while it still has entries on the stack
(i.e. it was never closed), a warning is emitted via warn(). Truthy map entries
live on that stack too, so they are covered; a map index holding only falsy
placeholders is not a leak and does not warn.
The close stack is designed to mirror the behavior of a sequence of <close>
variable declarations:
local a <close> = ...
local b <close> = ...
local c <close> = ...Here, c closes first, then b, then a. The close stack provides a dynamic
equivalent of this pattern — the closing order, error propagation, and error
replacement behavior all come directly from Lua's native to-be-closed machinery.
The close stack does not define these semantics; it delegates to real <close>
variables internally and inherits whatever behavior the Lua version and
implementation provides.
Notable differences between Lua versions:
- In Lua 5.4,
__closealways receives an error argument (nilwhen there is no error). This makes it impossible to distinguish between a normal close and anerror(nil). - In Lua 5.5,
__closereceives no error argument on normal close, andnilerrors are converted to strings.
For the sake of a sane implementation, we just treat a nil error as being
no error.
This is implemented via the actual Lua function stack — each entry gets a
<close> local. This decision was made because of the following requirements:
- The incoming error must be passed into each closer, because some need to make decisions based on it (e.g. commit-or-rollback guards).
- We cannot do
getmetatable(value).__close(value, err)directly, because__metatablecan be used to prevent metatable access. - An error thrown from a closer must become the error value seen by subsequent closers.
The only way to reliably satisfy all three is to assign each value to a real to-be-closed variable and let Lua's built-in closing semantics handle error propagation.
This does mean that the close stack depends on the user passing a proper auto-closeable object. If you violate this, the stack will throw an error on close, and leak parts of its stack.
The ordered stack is unwound 16 closers per function frame (an unrolling that
raises the effective capacity and reduces recursion overhead). The keyed map does
not need its own unwinder: because entries are const once set, map_set can just
push the closeable onto that same stack, so map values close through the exact
same machinery and in the same LIFO order as everything else. The map's own table
is purely a keyed index for map_get/map_pairs/map_len lookups, and holds no
closing responsibility of its own.
The index is kept as private state reached only through map_set/map_get/
map_pairs/map_len, never as a raw table. This is deliberate: a <close>
value is also <const>, and the whole close stack is built to honor that — you
can add resources but never quietly remove an individual one. A raw public table
would let a caller delete or overwrite an owned closeable and leak it, so
map_set enforces the const-once-set rule instead. (The const rule is also
exactly what makes the append trick sound: since an entry can never be replaced
or removed, pushing it once is enough.)
The public stack.map field is a proxy (see src/close-stack/_map-proxy.lua),
not the underlying table: a small table carrying a backreference to its owning
stack under a module-private key, with a metatable whose __index/__newindex/
__len/__pairs forward to the methods above. Since every operation goes
through map_set/map_get/etc., the ergonomic table syntax gets the exact same
guarantees as the methods, with no back door. pairs support relies on the
__pairs metamethod, which Lua 5.4 still honors.
Because entries use real function stack frames, the maximum number of entries
is bounded by the Lua call stack limit (around 600,000 by default). This was
previously implemented via a chain of pcalls, which removed the stack limit
but caused string error values to grow with each re-throw, eventually filling
memory. The stack frame approach is the better trade-off for any realistic
workload.