The previous
post introduced
lsdbus and showed the basics: methods, properties, signals. This time
we build something that actually does real work — a tiny D-Bus file
server — and use it as an excuse to look at add_io, the hook that
lets you mix arbitrary file descriptors into the lsdbus event loop.
The server will:
- expose
Info,Rename, andDeletemethods over D-Bus - monitor its directory with linotify
- emit a
Changedsignal whenever something moves, appears, or disappears
Total: ~80 loc. No threads, no polling, no C.
The interface
Define the D-Bus contract as a Lua table — same as always.
local fs_if = {
name = "org.example.FileServer",
methods = {
Info = { ... },
Rename = { ... },
Delete = { ... },
},
properties = {
Dir = { access="read", type="s",
get=function(vt) return vt.dir end },
},
signals = {
Changed = {
{ name="name", type="s" },
{ name="event", type="s" },
},
},
}
The Changed signal carries a filename and an event string like
"created" or "deleted". The Dir property lets clients ask which
directory is being served.
Methods
Info - stat a file and hand back size, type, and mtime.
posix.stat returns a plain table; we pluck out three fields and
return them as separate D-Bus out-args (types t, s, t):
Info = {
{ direction="in", name="name", type="s" },
{ direction="out", name="size", type="t" },
{ direction="out", name="type", type="s" },
{ direction="out", name="mtime", type="t" },
handler = function(vt, name)
local st = posix.stat(vt.dir .. "/" .. name)
if not st then
error("org.freedesktop.DBus.Error.FileNotFound|not found: " .. name)
end
return st.size, st.type, st.mtime
end
},
D-Bus errors are raised with error("ErrorName|message") — lsdbus
unpacks the pipe-separated string and sends a proper D-Bus error reply.
Rename and Delete delegate straight to the Lua standard
library. If the OS call fails, the error string becomes the D-Bus
fault:
Rename = {
{ direction="in", name="from", type="s" },
{ direction="in", name="to", type="s" },
handler = function(vt, from, to)
local ok, err = os.rename(vt.dir.."/"..from, vt.dir.."/"..to)
if not ok then error("org.freedesktop.DBus.Error.Failed|"..err) end
end
},
Delete = {
{ direction="in", name="name", type="s" },
handler = function(vt, name)
local ok, err = os.remove(vt.dir.."/"..name)
if not ok then error("org.freedesktop.DBus.Error.Failed|"..err) end
end
},
The inotify bridge: add_io
Here's the interesting bit. The lsdbus event loop is backed by
sd-event, which is really just an epoll loop under the hood.
bus:add_io(fd, mask, callback) hands any file descriptor to that
loop with an epoll interest mask. When the fd becomes readable (or
writable), the callback fires — inside the same loop iteration that
handles D-Bus traffic. No threads, no select, no separate goroutine:
everything is single-threaded and cooperative.
inotify fits perfectly. inotify.init() returns a handle backed by a
kernel fd. When a watched directory changes, the kernel queues one or
more event structs on that fd. We register it with add_io and drain
it in the callback:
local ino = inotify.init()
ino:addwatch(dir,
inotify.IN_CREATE | inotify.IN_DELETE |
inotify.IN_CLOSE_WRITE | inotify.IN_MOVED_FROM | inotify.IN_MOVED_TO)
b:add_io(ino:getfd(), lsdb.EPOLLIN, function()
for _, e in ipairs(ino:read()) do
if e.name ~= "" then
srv:emit("Changed", e.name, mask_to_event(e.mask))
end
end
end)
ino:getfd() exposes the raw file descriptor; lsdb.EPOLLIN says
"wake me on data"; ino:read() returns the buffered events as a Lua
table. Each event has a .name (the filename) and a .mask
(bitmask). We convert the mask to a human-readable string:
local function mask_to_event(mask)
if mask & inotify.IN_CREATE ~= 0 then return "created" end
if mask & inotify.IN_DELETE ~= 0 then return "deleted" end
if mask & inotify.IN_CLOSE_WRITE ~= 0 then return "modified" end
if mask & inotify.IN_MOVED_FROM ~= 0 then return "moved_from" end
if mask & inotify.IN_MOVED_TO ~= 0 then return "moved_to" end
return "other"
end
The Changed signal fires for any change — whether triggered
through the D-Bus Rename/Delete methods or by something else
touching the directory externally. inotify doesn't care who did it.
Wiring it up
local b = lsdb.open("user")
b:request_name("org.example.FileServer")
local srv = lsdb.server.new(b, "/", fs_if)
srv.dir = dir -- stash the directory in the vtable state
-- ... inotify setup as above ...
b:add_signal(lsdb.SIGINT, function() b:exit_loop() end)
b:loop()
lsdb.server.new returns the vtable object (srv). It's also a
plain Lua table, so srv.dir = dir just works — handler functions
receive it as the first argument vt.
Try it
$ mkdir /tmp/watch
$ lua fileserver.lua /tmp/watch
In another terminal, poke it with busctl:
# introspect
$ busctl --user introspect org.example.FileServer /
# create a file and check info
$ echo hello > /tmp/watch/foo.txt
$ busctl --user call org.example.FileServer / \
org.example.FileServer Info s "foo.txt"
t s t 6 "regular" 1746057600
# rename it
$ busctl --user call org.example.FileServer / \
org.example.FileServer Rename ss "foo.txt" "bar.txt"
# watch signals while doing the above
$ busctl --user monitor --user
The monitor output will show a Changed signal for every file
system event, including the ones the D-Bus methods cause:
org.example.FileServer / org.example.FileServer Changed ss "foo.txt" "created"
org.example.FileServer / org.example.FileServer Changed ss "foo.txt" "moved_from"
org.example.FileServer / org.example.FileServer Changed ss "bar.txt" "moved_to"
Full listing
-- fileserver.lua
local lsdb = require("lsdbus")
local inotify = require("inotify")
local posix = require("posix")
local dir = arg[1] or error("usage: fileserver.lua <dir>")
local function mask_to_event(mask)
if mask & inotify.IN_CREATE ~= 0 then return "created" end
if mask & inotify.IN_DELETE ~= 0 then return "deleted" end
if mask & inotify.IN_CLOSE_WRITE ~= 0 then return "modified" end
if mask & inotify.IN_MOVED_FROM ~= 0 then return "moved_from" end
if mask & inotify.IN_MOVED_TO ~= 0 then return "moved_to" end
return "other"
end
local fs_if = {
name = "org.example.FileServer",
methods = {
Info = {
{ direction="in", name="name", type="s" },
{ direction="out", name="size", type="t" },
{ direction="out", name="type", type="s" },
{ direction="out", name="mtime", type="t" },
handler = function(vt, name)
local st = posix.stat(vt.dir .. "/" .. name)
if not st then
error("org.freedesktop.DBus.Error.FileNotFound|not found: " .. name)
end
return st.size, st.type, st.mtime
end
},
Rename = {
{ direction="in", name="from", type="s" },
{ direction="in", name="to", type="s" },
handler = function(vt, from, to)
local ok, err = os.rename(vt.dir.."/"..from, vt.dir.."/"..to)
if not ok then error("org.freedesktop.DBus.Error.Failed|"..err) end
end
},
Delete = {
{ direction="in", name="name", type="s" },
handler = function(vt, name)
local ok, err = os.remove(vt.dir.."/"..name)
if not ok then error("org.freedesktop.DBus.Error.Failed|"..err) end
end
},
},
properties = {
Dir = { access="read", type="s", get=function(vt) return vt.dir end },
},
signals = {
Changed = {
{ name="name", type="s" },
{ name="event", type="s" },
},
},
}
local b = lsdb.open("user")
b:request_name("org.example.FileServer")
local srv = lsdb.server.new(b, "/", fs_if)
srv.dir = dir
local ino = inotify.init()
ino:addwatch(dir,
inotify.IN_CREATE | inotify.IN_DELETE |
inotify.IN_CLOSE_WRITE | inotify.IN_MOVED_FROM | inotify.IN_MOVED_TO)
b:add_io(ino:getfd(), lsdb.EPOLLIN, function()
for _, e in ipairs(ino:read()) do
if e.name ~= "" then
srv:emit("Changed", e.name, mask_to_event(e.mask))
end
end
end)
b:add_signal(lsdb.SIGINT, function() b:exit_loop() end)
b:loop()
add_io is the general escape hatch for anything file-descriptor
shaped: sockets, pipes, hardware event devices, serial ports. The
same pattern — init(), getfd(), add_io, drain in callback —
works for any of them. The event loop stays single-threaded and
deterministic; you just teach it about more fds.