Skip to content

lua: asynchronous invoke, and the descriptor a request answered with - #26

Open
ddimension wants to merge 2 commits into
openwrt:masterfrom
ddimension:lua-async
Open

lua: asynchronous invoke, and the descriptor a request answered with#26
ddimension wants to merge 2 commits into
openwrt:masterfrom
ddimension:lua-async

Conversation

@ddimension

@ddimension ddimension commented Aug 22, 2026

Copy link
Copy Markdown

The Lua binding offers conn:call() and nothing else. It runs ubus_invoke(),
which spins its own event loop until the answer arrives, so in a program that
already runs uloop everything else stops for the duration — timers, sockets,
other callbacks.

conn:call_async(object, method, params, callback [, timeout]) hands the
request to the uloop the connection is already attached to and returns
immediately. The callback runs exactly once with (result_or_nil, status).

local ubus  = require "ubus"
local uloop = require "uloop"

uloop.init()
local conn = ubus.connect()

-- returns at once; the callback runs from the uloop
conn:call_async("network.interface.lan", "status", {}, function(result, status)
    if status ~= 0 then
        print("failed:", status)          -- 7 is UBUS_STATUS_TIMEOUT
    else
        print("lan is up:", result.up)
    end
end)

-- with a deadline of three seconds instead of the connection's default
conn:call_async("file", "exec", { command = "sleep", params = { "10" } },
    function(_, status) print("status", status) end, 3)

uloop.run()

Why it matters here

The process this was written for runs an on-AP RADIUS server for hostapd out of
the same uloop. With macaddr_acl=2 hostapd stays silent until that answer is
in, so every blocking ubus call was a window in which no station could
associate.

Demonstration

lua/test_async.lua counts timer ticks across a blocking and a deferred call
and then shows the deadline. On an ipq807x access point:

blocking call: 0 timer ticks while it ran
deferred call: 107 timer ticks while it ran, status 0
timed out as expected, status 7 (UBUS_STATUS_TIMEOUT is 7)

Three things libubus leaves to the caller

  • No deadline. ubus_complete_request_async() takes no timeout — only the
    synchronous ubus_complete_request() does — so a peer that never answers
    keeps the request on the pending list forever. Each call therefore carries
    its own uloop_timeout and reports UBUS_STATUS_TIMEOUT. It defaults to the
    connection's timeout, the same one the synchronous path uses.
  • ubus_abort_request() does not run complete_cb, it only unlinks the
    request. Exactly one of the two paths frees the state, guarded by a flag.
  • lua_pcall, not lua_call. The callback runs from a uloop callback, and
    an error thrown out of there would longjmp past libubus' own bookkeeping.
    The existing callbacks in this file use lua_call; I did not think that a
    reason to add another one.

Testing

  • Built with the full option set from the top level CMakeLists.txt
    (-Wall -Werror -Wextra -Wformat -Werror=format-security -Werror=format-nonliteral -Werror=implicit-function-declaration -Os -g3 -Wmissing-declarations -Wno-unused-parameter -std=gnu99): no new warnings,
    and the unmodified file is equally clean under the same flags.
  • Cross-built for aarch64_cortex-a53 and for 32 bit arm_cortex-a7 through
    the OpenWrt 25.12 SDK, and against master.
  • Running on seven access points (ipq807x, mediatek/filogic, ipq40xx), driving
    both ad-hoc calls and batches of about thirty.

Nothing the existing binding does changes; call_async is added beside call.


Second commit: the descriptor a request answered with

A ubus method may answer with a file descriptor instead of with data.
ubus call log read '{"stream":true}' prints nothing for exactly that reason —
logd writes the log down a pipe, and the CLI does not render one. libubus has
carried the mechanism all along (ubus_fd_handler_t, req->fd_cb), and
ubus_process_req_msg() closes the descriptor itself when nothing claims it.

call_async() now claims it and passes it as a third value, so every
existing cb(result, status) keeps working and one that wants the descriptor
takes cb(result, status, fd).

A descriptor alone is of no use to Lua, and not for want of taste: io.* wants
a FILE*, and on the OpenWrt devices this was written for there is no posix,
no nixio and no lfsluasocket is there and cannot wrap one either. So
three small functions come with it:

ubus.read_fd(fd [, bytes]) the bytes, or nil plus 'eof' or 'again'. Never blocks — the descriptor is put into non-blocking mode on first use, which is what a reader driven by uloop wants
ubus.close_fd(fd) whoever is handed one has to close it
ubus.blob_decode(buffer) one blob attribute out of a byte string, plus how many bytes it consumed

blob_decode is needed because the stream is not text. logd writes blob
attributes, each length prefixed and already carrying its fields — on an access
point a record is 116 bytes holding msg, id, priority, source and
time. Framing belongs in C because the format does; buffering stays in Lua,
where a growing string is the whole of it.

uloop needs no change: get_sock_fd() in libubox already takes a bare number.

local buf = ""
conn:call_async("log", "read", { stream = true, lines = 0 }, function(res, status, fd)
    uloop.fd_add(fd, function()
        local data, why = ubus.read_fd(fd, 4096)
        if not data then
            if why == "eof" then ubus.close_fd(fd) end
            return
        end
        buf = buf .. data
        while true do
            local rec, used = ubus.blob_decode(buf)
            if not rec then break end          -- "incomplete" is the ordinary case
            buf = buf:sub(used + 1)
            print(rec.id, rec.priority, rec.msg)
        end
    end, uloop.ULOOP_READ)
end)

Measured end to end on OpenWrt 25.12: fd 7 delivered, uloop reports it readable,
read_fd returns the bytes, blob_decode yields
{id = 10544, msg = "fdtest: blob test eins", priority = 28, source = 1, time = 1787514663721},
close_fd returns true.

conn:call() runs ubus_invoke(), which spins its own event loop until the answer
arrives. In a daemon that already runs uloop this stalls everything else it is
doing — timers, sockets, other callbacks — for as long as the peer takes.

conn:call_async(object, method, params, callback [, timeout]) hands the request
to the uloop the connection is already attached to and returns immediately. The
callback runs exactly once with (result_or_nil, status).

lua/test_async.lua shows the difference by counting timer ticks across both,
and demonstrates the deadline. On an ipq807x access point:

  blocking call: 0 timer ticks while it ran
  deferred call: 107 timer ticks while it ran, status 0
  timed out as expected, status 7 (UBUS_STATUS_TIMEOUT is 7)

What this is for: the same process answers RADIUS for hostapd there, and with
macaddr_acl=2 the access point stays silent until that answer is in. Every
blocking ubus call was a window in which no station could associate.

Three things libubus leaves to the caller and this has to get right:

 - There is no deadline on an asynchronous request. A peer that never answers
   keeps it on the pending list forever, so each call carries its own uloop
   timeout and reports UBUS_STATUS_TIMEOUT. It defaults to the connection's
   timeout, the same one the synchronous path uses.
 - ubus_abort_request() does not run complete_cb, it only unlinks the request.
   Exactly one of the two paths therefore frees the state, guarded by a flag.
 - The callback goes through lua_pcall, not lua_call: it runs from a uloop
   callback, and an error thrown out of there would longjmp past libubus' own
   bookkeeping.

Signed-off-by: André Valentin <avalentin@marcant.net>
A ubus method may answer with a file descriptor instead of with data.
'log read' with stream:true is the case this exists for: logd writes the
log down a pipe rather than returning it, which is why
`ubus call log read '{"stream":true}'` prints nothing — the CLI does not
render one. libubus has carried the mechanism all along: libubus.h
ubus_fd_handler_t, req->fd_cb, and ubus_process_req_msg() closes the
descriptor itself when no handler claims it.

call_async() now claims it and passes it to the callback as a third value.
Third value rather than a second callback because Lua discards extra
arguments: every existing cb(result, status) keeps working unchanged, and
one that wants the descriptor takes cb(result, status, fd).

A descriptor on its own is of no use to Lua, and not for want of taste.
io.* wants a FILE*; posix, nixio and lfs are on none of the OpenWrt
devices this was written for, and luasocket, which is there, cannot wrap
a descriptor either. Handing out a number nobody can read from or close
would be a feature in name only, so three small functions come with it:

  ubus.read_fd(fd [, bytes])  the bytes, or nil plus 'eof' or 'again'.
                              Never blocks: the descriptor is put into
                              non-blocking mode on first use, which is
                              what a reader driven by uloop wants — a
                              spurious wakeup then costs an EAGAIN rather
                              than a stalled process.
  ubus.close_fd(fd)           whoever is handed one has to close it.
  ubus.blob_decode(buffer)    one blob attribute out of a byte string,
                              with the number of bytes it consumed.

blob_decode is needed because the stream is not text. logd writes blob
attributes, each length prefixed and already carrying its fields: on an
access point a record is 116 bytes holding msg, id, priority, source and
time. That is better than the rendered syslog line, which would have to
be taken apart again with a regular expression, and id is a sequence
number, so a reader can tell when it has missed something. Framing
belongs in C because the format does; buffering stays in Lua, where a
growing string is the whole of it.

uloop needs no change: get_sock_fd() in libubox already accepts a bare
number, so uloop.fd_add(fd, cb, ULOOP_READ) works as it stands.

Measured end to end on OpenWrt 25.12: call_async('log', 'read',
{stream = true, lines = 0}) delivers fd 7, uloop reports it readable,
read_fd returns the bytes, blob_decode turns them into
{id = 10544, msg = 'fdtest: blob test eins', priority = 28, source = 1,
time = 1787514663721}, and close_fd returns true.

Signed-off-by: André Valentin <avalentin@marcant.net>
@ddimension ddimension changed the title lua: add an asynchronous invoke lua: asynchronous invoke, and the descriptor a request answered with Aug 23, 2026
ddimension pushed a commit to ddimension/openwrt-repo that referenced this pull request Aug 23, 2026
… to use it

A ubus method may answer with a file descriptor instead of with data. 'log
read' with stream:true is the one this exists for, and it is why
`ubus call log read '{"stream":true}'` prints nothing — the CLI does not render
one. libubus has carried the mechanism all along (ubus_fd_handler_t,
req->fd_cb) and closes the descriptor itself when nothing claims it.

call_async() now claims it and passes it as a third value, so every existing
cb(result, status) keeps working and one that wants it takes a third parameter.

The three functions alongside are not extras. On the access points this feed
builds for there is no posix, no nixio and no lfs — checked on the fleet — and
luasocket, which is there, cannot wrap a descriptor either; io.* wants a FILE*.
A fd handed to Lua and nothing else would be a number nobody could read from or
close. So: read_fd, close_fd, and blob_decode, the last because the stream is
not text — logd writes length prefixed blob attributes carrying msg, id,
priority, source and time.

Measured end to end on ap-av-attic: fd delivered, uloop reports it readable,
read_fd returns the bytes, blob_decode turns them into a table, close_fd
returns true. Also sent upstream, on top of the call_async patch, as the second
commit of openwrt/ubus#26.

Signed-off-by: André Valentin <avalentin@marcant.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants