A tiny D-Bus file server in 80 lines of Lua

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, and Delete methods over D-Bus
  • monitor its directory with linotify
  • emit a Changed signal 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.