Skip to content

hotplug: load rule fragments from a drop-in directory - #48

Open
dangowrt wants to merge 5 commits into
openwrt:mainfrom
dangowrt:procd-modular-hotplug
Open

dangowrt wants to merge 5 commits into
openwrt:mainfrom
dangowrt:procd-modular-hotplug

Conversation

@dangowrt

Copy link
Copy Markdown
Member

/etc/hotplug.json is a single file, so a package that needs device-node policy of its own has to edit the file it shares with every other package, or fall back to a shell script under /etc/hotplug.d/. This series lets procd run a directory of rule fragments alongside the main file, and lets it notice when that directory changes.

The interpreter already had everything needed on the libubox side: handle_file may return a chain of files linked through ::next, and __json_script_run() walks the chain, a contract documented since the original json_script commit. No caller ever built such a chain, so procd's handle_file is where the work belongs. With this in, an [ "include", "/etc/hotplug.json.d/*.json" ] in hotplug.json runs every fragment in sorted glob order, and a package can own its own rules by installing one file.

The commits

  • c82f404 drops the json-c object that rule_handle_file() leaked on every rule file it parsed.
  • c8a976c splits rule_load_file() out of rule_handle_file() and builds the ::next chain from glob(3) when the requested name carries a wildcard. The chain head keeps the pattern as its avl key, because json_script_get_file() caches by the requested name and ignores a failed avl_insert(); keying the head by its own path would reparse and leak every fragment on every uevent.
  • 8d1890e adds a hotplug ubus object with a reload method that drops the parsed-file cache, as a manual and debug entry point.
  • b63f958 moves the inotify plumbing that hotplug-dispatch.c hand-rolled for /etc/hotplug.d into utils.c, behind inotify_watch_add(path, mask, callback), and converts that watch to it. The /etc/hotplug.d watch keeps its mask and its IN_ISDIR filtering.
  • ed4f02a puts a watch on the drop-in directory through that helper, so a fragment installed, altered or removed at runtime takes effect on the next uevent.

Semantics worth knowing when writing a fragment

[ "return" ] inside a fragment ends that fragment and nothing more. handle_include() runs the chain through __json_script_run() and returns 0 whatever the chain did, so the parent file carries on afterwards, and so does the next fragment in the chain. First-match-wins therefore rests on the parent's catch-all makedev failing with EEXIST once a fragment has already created the node, which is exactly how the shipped ^snd and ^tty rules have always behaved.

The watched directory is derived from the rule file: /etc/hotplug.json is paired with /etc/hotplug.json.d, and the preinit instance started as procd -h /etc/hotplug-preinit.json gets /etc/hotplug-preinit.json.d. The watch is set up in hotplug(), which is the one path both instances share; the preinit instance runs without ubus, so it can only be reached there. A directory that does not exist at watch time is reported once and procd carries on.

The mask is IN_CREATE | IN_CLOSE_WRITE | IN_MOVED_TO | IN_DELETE | IN_MOVED_FROM | IN_DELETE_SELF | IN_MOVE_SELF. IN_MOVED_TO is the one a package manager needs, since apk writes a temporary name and renameat()s it into place; IN_CLOSE_WRITE covers an edit or a shell redirect; IN_DELETE and IN_MOVED_FROM cover removal, which a post-install hook structurally cannot. IN_MODIFY is absent on purpose: it fires per write() and would drop the cache in the middle of a fragment being written.

Dropping the cache is json_script_free() followed by json_script_init(), and json_script parses a file on first use, so a package installing several fragments at once costs several cheap cache drops and exactly one reparse at the next uevent. No coalescing timer is involved.

Testing

Built with procd's own -Os -Wall -Werror --std=gnu99 -Wmissing-declarations, for x86_64 musl, all targets.

The rule semantics were exercised in a harness linking the real json_script.c against a copy of rule_handle_file(), fed the shipped hotplug.json with an include added and three fragments in the drop-in directory: a claimed node lands in its fragment's group and the parent's catch-all then fails harmlessly, an unclaimed node gets only the catch-all, dri/card0 and dri/renderD128 land in different groups, null still short-circuits, and remove is unaffected. The first uevent parses four files and every later one parses none, which is the chain-caching contract above. Repeated free/init cycles with fragments added and removed in between are clean under valgrind --leak-check=full --error-exitcode=9.

The runtime behaviour was checked against the built binary in a user and network namespace, driving real uevents over NETLINK_KOBJECT_UEVENT and real filesystem operations on the drop-in directory. /proc/<pid>/fdinfo confirms the watch sits on the expected inode with mask 0xfc8, for /etc/hotplug.json.d and for /etc/hotplug-preinit.json.d alike. A fragment renamed in, deleted, edited in place, renamed out, renamed back, and several dropped at once each take effect on the next uevent, and removing the drop-in directory entirely leaves only the parent's rules. Against a private ubusd, the refactored /etc/hotplug.d watch still registers and unregisters hotplug.<subsystem> as subsystem directories are created, deleted, renamed in and renamed out.

rule_handle_file() converts the parsed json-c object into a blobmsg
buffer but never drops its reference, so the object tree stays
allocated for the lifetime of procd. Only one rule file is ever
loaded, so a single object is leaked rather than one per uevent.

Fixes: 54108c7 ("fix hotplug")
Signed-off-by: Daniel Golle <daniel@makrotopia.org>
There is no way for a package to extend the hotplug rules short of
editing the single /etc/hotplug.json it shares with everyone else.
json_script already caters for this: its handle_file callback may
return a chain of files linked through ::next, which the interpreter
runs in order, but no caller ever built one.

Build that chain from glob(3) whenever the requested name contains a
wildcard, so an "include" of /etc/hotplug.json.d/*.json runs every
fragment in sorted order. json_script_get_file() caches by the
requested name and ignores a failed avl_insert(), so the head of the
chain carries the pattern as its key; keying it by its own path would
reparse and leak every fragment on every uevent.

Signed-off-by: Daniel Golle <daniel@makrotopia.org>
Parsed rule files are cached for the lifetime of procd, so a package
dropping a fragment into /etc/hotplug.json.d only takes effect after
a reboot, and procd is PID 1.

Add a "hotplug" object with a "reload" method that drops the cache;
the files are read again on the next uevent. The object is only
registered once hotplug() has set up the rule engine: procd started
as anything other than PID 1 skips that, leaving the json_script
context uninitialised.

Signed-off-by: Daniel Golle <daniel@makrotopia.org>
hotplug-dispatch.c hand-rolls the only inotify user in procd: an
inotify fd on a uloop_fd, a read buffer sized for one maximal event,
and a loop walking the events of a single read. Any second watch in
procd would have to repeat all of it.

Move that plumbing into utils.c behind inotify_watch_add(), which takes
a path, a mask and a per-event callback, and let all watches share one
read buffer. Reads happen inside the uloop callback, so two watches can
never use the buffer at the same time. The /etc/hotplug.d watch keeps
its mask and its IN_ISDIR filtering.

Signed-off-by: Daniel Golle <daniel@makrotopia.org>
Parsed rule files are cached for the lifetime of procd, so a fragment
added to or removed from the drop-in directory only takes effect after
a reboot.

Watch the directory that sits next to the rule file, /etc/hotplug.json.d
for the instance procd starts itself and /etc/hotplug-preinit.json.d for
the one procd -h runs during preinit, and drop the cache on every event.
The mask covers a fragment appearing, being replaced by a rename, being
altered in place and being removed. hotplug() is the one setup path both
instances share: the preinit instance runs without ubus, so a watch
placed on the ubus connect path would leave it out. json_script parses a
rule file on first use, so dropping the cache costs nothing until the
next uevent arrives, however many fragments a package installs at once.

Signed-off-by: Daniel Golle <daniel@makrotopia.org>
@dangowrt
dangowrt force-pushed the procd-modular-hotplug branch from ed4f02a to 68449eb Compare September 25, 2026 23:20
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.

1 participant