nd: poweroff with progress report to ngui

the daemon now sends info about the system shutdown progress: a list of
important services which may require up to several minutes to stop such
as lnd lightning daemon and bitcoin core.

see next commit for how this info is displayed and used by the GUI.
pull/22/head
alex 1 year ago
parent a7c560e92a
commit 78df4ad7ee
Signed by: x1ddos
GPG Key ID: FDEFB4A63CBD8460

@ -114,6 +114,9 @@ pub fn build(b: *std.build.Builder) void {
tests.setTarget(target);
tests.setBuildMode(mode);
tests.linkLibC();
tests.addPackage(buildopts.getPackage("build_options"));
nifbuild.addPkg(b, tests, "lib/nif");
const f = b.option([]const u8, "test-filter", "run tests matching the filter");
tests.setFilter(f);

@ -3,11 +3,11 @@ const mem = std.mem;
const Thread = std.Thread;
const WPACtrl = opaque {};
const WPAReqCallback = *const fn ([*:0]const u8, usize) callconv(.C) void;
pub const ReqCallback = *const fn ([*:0]const u8, usize) callconv(.C) void;
extern fn wpa_ctrl_open(ctrl_path: [*:0]const u8) ?*WPACtrl;
extern fn wpa_ctrl_close(ctrl: *WPACtrl) void;
extern fn wpa_ctrl_request(ctrl: *WPACtrl, cmd: [*:0]const u8, clen: usize, reply: [*:0]u8, rlen: *usize, cb: ?WPAReqCallback) c_int;
extern fn wpa_ctrl_request(ctrl: *WPACtrl, cmd: [*:0]const u8, clen: usize, reply: [*:0]u8, rlen: *usize, cb: ?ReqCallback) c_int;
extern fn wpa_ctrl_pending(ctrl: *WPACtrl) c_int;
extern fn wpa_ctrl_recv(ctrl: *WPACtrl, reply: [*:0]u8, reply_len: *usize) c_int;
@ -130,7 +130,7 @@ pub const Control = struct {
/// send a command to the control interface, returning a response owned by buf.
/// callback receives a message from the same buf.
pub fn request(self: Self, cmd: [:0]const u8, buf: [:0]u8, callback: ?WPAReqCallback) Error![]const u8 {
pub fn request(self: Self, cmd: [:0]const u8, buf: [:0]u8, callback: ?ReqCallback) Error![]const u8 {
//self.mu.lock();
//defer self.mu.unlock();
var n: usize = buf.len;

@ -24,6 +24,7 @@ pub const Message = union(MessageTag) {
wifi_connect: WifiConnect,
network_report: NetworkReport,
get_network_report: GetNetworkReport,
poweroff_progress: PoweroffProgress,
pub const WifiConnect = struct {
ssid: []const u8,
@ -39,6 +40,16 @@ pub const Message = union(MessageTag) {
pub const GetNetworkReport = struct {
scan: bool, // true starts a wifi scan and send NetworkReport only after completion
};
pub const PoweroffProgress = struct {
services: []const Service,
pub const Service = struct {
name: []const u8,
stopped: bool,
err: ?[]const u8,
};
};
};
/// it is important to preserve ordinal values for future compatiblity,
@ -54,7 +65,9 @@ pub const MessageTag = enum(u16) {
standby = 0x07,
// ngui -> nd: resume screen due to user touch; no reply
wakeup = 0x08,
// next: 0x09
// nd -> ngui: reports poweroff progress
poweroff_progress = 0x09,
// next: 0x0a
};
/// reads and parses a single message from the input stream reader.
@ -92,6 +105,9 @@ pub fn read(allocator: mem.Allocator, reader: anytype) !Message {
.get_network_report => Message{
.get_network_report = try json.parse(Message.GetNetworkReport, &jstream, jopt),
},
.poweroff_progress => Message{
.poweroff_progress = try json.parse(Message.PoweroffProgress, &jstream, jopt),
},
};
}
@ -106,6 +122,7 @@ pub fn write(allocator: mem.Allocator, writer: anytype, msg: Message) !void {
.wifi_connect => try json.stringify(msg.wifi_connect, jopt, data.writer()),
.network_report => try json.stringify(msg.network_report, jopt, data.writer()),
.get_network_report => try json.stringify(msg.get_network_report, jopt, data.writer()),
.poweroff_progress => try json.stringify(msg.poweroff_progress, jopt, data.writer()),
}
if (data.items.len > std.math.maxInt(u64)) {
return Error.CommWriteTooLarge;
@ -125,6 +142,22 @@ pub fn free(allocator: mem.Allocator, m: Message) void {
}
}
// TODO: use fifo
//
// var buf = std.fifo.LinearFifo(u8, .Dynamic).init(t.allocator);
// defer buf.deinit();
// const w = buf.writer();
// const r = buf.reader();
// try w.writeAll("hello there");
//
// var b: [100]u8 = undefined;
// var n = try r.readAll(&b);
// try t.expectEqualStrings("hello there", b[0..n]);
//
// try w.writeAll("once more");
// n = try r.readAll(&b);
// try t.expectEqualStrings("once more2", b[0..n]);
test "read" {
const t = std.testing;

@ -111,13 +111,17 @@ fn parseArgs(gpa: std.mem.Allocator) !NdArgs {
return flags;
}
/// quit signals nd to exit.
/// TODO: thread-safety?
var quit = false;
/// sigquit signals nd main loop to exit.
/// since both the loop and sighandler are on the same thread, it must
/// not be guarded by a mutex which otherwise leads to a dealock.
var sigquit = false;
fn sighandler(sig: c_int) callconv(.C) void {
logger.info("got signal {}; exiting...\n", .{sig});
quit = true;
logger.info("received signal {}", .{sig});
switch (sig) {
os.SIG.INT, os.SIG.TERM => sigquit = true,
else => {},
}
}
pub fn main() !void {
@ -134,9 +138,7 @@ pub fn main() !void {
// reset the screen backlight to normal power regardless
// of its previous state.
screen.backlight(.on) catch |err| {
logger.err("backlight: {any}", .{err});
};
screen.backlight(.on) catch |err| logger.err("backlight: {any}", .{err});
// start ngui, unless -nogui mode
var ngui = std.ChildProcess.init(&.{args.gui.?}, gpa);
@ -156,9 +158,8 @@ pub fn main() !void {
//ngui.uid = uiuser.uid;
//ngui.gid = uiuser.gid;
// ngui.env_map = ...
ngui.spawn() catch |err| {
fatal("unable to start ngui: {any}", .{err});
};
ngui.spawn() catch |err| fatal("unable to start ngui: {any}", .{err});
// TODO: thread-safety, esp. uiwriter
const uireader = ngui.stdout.?.reader();
const uiwriter = ngui.stdin.?.writer();
@ -175,24 +176,25 @@ pub fn main() !void {
.flags = 0,
};
try os.sigaction(os.SIG.INT, &sa, null);
//TODO: try os.sigaction(os.SIG.TERM, &sa, null);
try os.sigaction(os.SIG.TERM, &sa, null);
// start network monitor
var ctrl = try nif.wpa.Control.open(args.wpa.?);
defer ctrl.close() catch {};
var nd: Daemon = .{
.allocator = gpa,
.uiwriter = uiwriter,
.wpa_ctrl = ctrl,
};
var nd = try Daemon.init(gpa, uiwriter, args.wpa.?);
defer nd.deinit();
try nd.start();
// send the UI network report right away, without scanning wifi
nd.reportNetworkStatus(.{ .scan = false });
// comm with ui loop; run until exit is requested
var poweroff = false;
while (!quit) {
// ngui -> nd comm loop; run until exit is requested
// TODO: move this loop to Daemon.zig? but what about quit and keep ngui running
while (!sigquit) {
time.sleep(100 * time.ns_per_ms);
if (poweroff) {
// GUI is not expected to send anything back at this point,
// so just loop until we're terminated by a SIGTERM (sigquit).
continue;
}
// note: uireader.read is blocking
// TODO: handle error.EndOfStream - ngui exited
const msg = comm.read(gpa, uireader) catch |err| {
@ -205,9 +207,11 @@ pub fn main() !void {
logger.info("received pong from ngui", .{});
},
.poweroff => {
logger.info("poweroff requested; terminating", .{});
quit = true;
poweroff = true;
nd.beginPoweroff() catch |err| {
logger.err("beginPoweroff: {any}", .{err});
poweroff = false;
};
},
.get_network_report => |req| {
nd.reportNetworkStatus(.{ .scan = req.scan });
@ -234,39 +238,10 @@ pub fn main() !void {
comm.free(gpa, msg);
}
// shutdown
// reached here due to sig TERM or INT;
// note: poweroff does not terminate the loop and instead initiates
// a system shutdown which in turn should terminate this process via a SIGTERM.
// so, there's no difference whether we're exiting due to poweroff of a SIGTERM here.
_ = ngui.kill() catch |err| logger.err("ngui.kill: {any}", .{err});
nd.stop();
if (poweroff) {
svShutdown(gpa);
var off = std.ChildProcess.init(&.{"poweroff"}, gpa);
_ = try off.spawnAndWait();
}
}
/// shut down important services manually.
/// TODO: make this OS-agnostic
fn svShutdown(allocator: std.mem.Allocator) void {
// sv waits 7sec by default but bitcoind and lnd need more
// http://smarden.org/runit/
const Argv = []const []const u8;
const cmds: []const Argv = &.{
&.{ "sv", "-w", "600", "stop", "lnd" },
&.{ "sv", "-w", "600", "stop", "bitcoind" },
};
var procs: [cmds.len]?std.ChildProcess = undefined;
for (cmds) |argv, i| {
var p = std.ChildProcess.init(argv, allocator);
if (p.spawn()) {
procs[i] = p;
} else |err| {
logger.err("{s}: {any}", .{ argv, err });
}
}
for (procs) |_, i| {
var p = procs[i];
if (p != null) {
_ = p.?.wait() catch |err| logger.err("{any}", .{err});
}
}
}

@ -1,5 +1,16 @@
///! daemon watches network status and communicates updates to the gui
///! using uiwriter
//! daemon watches network status and communicates updates to the GUI using uiwriter.
//! public fields are allocator
//! usage example:
//!
//! var ctrl = try nif.wpa.Control.open("/run/wpa_supplicant/wlan0");
//! defer ctrl.close() catch {};
//! var nd: Daemon = .{
//! .allocator = gpa,
//! .uiwriter = ngui_stdio_writer,
//! .wpa_ctrl = ctrl,
//! };
//! try nd.start();
const std = @import("std");
const mem = std.mem;
const time = std.time;
@ -8,41 +19,137 @@ const nif = @import("nif");
const comm = @import("../comm.zig");
const screen = @import("../ui/screen.zig");
const types = @import("../types.zig");
const SysService = @import("SysService.zig");
const logger = std.log.scoped(.netmon);
// pub fields
allocator: mem.Allocator,
uiwriter: std.fs.File.Writer, // ngui stdin
wpa_ctrl: nif.wpa.Control, // guarded by mu once start'ed
wpa_ctrl: types.WpaControl, // guarded by mu once start'ed
// private fields
/// guards all the fields below to sync between pub fns and main/poweroff threads.
mu: std.Thread.Mutex = .{},
quit: bool = false, // tells daemon to quit
main_thread: ?std.Thread = null, // non-nill if started
want_report: bool = false,
/// daemon state
state: enum {
stopped,
running,
poweroff,
},
main_thread: ?std.Thread = null,
poweroff_thread: ?std.Thread = null,
want_stop: bool = false, // tells daemon main loop to quit
want_network_report: bool = false,
want_wifi_scan: bool = false,
wifi_scan_in_progress: bool = false,
report_ready: bool = true, // no need to scan for an immediate report
network_report_ready: bool = true, // no need to scan for an immediate report
wpa_save_config_on_connected: bool = false,
/// system services actively managed by the daemon.
/// these are stop'ed during poweroff and their shutdown progress sent to ngui.
/// initialized in start and never modified again: ok to access without holding self.mu.
services: []SysService = &.{},
const Daemon = @This();
/// callers must deinit when done.
pub fn init(a: std.mem.Allocator, iogui: std.fs.File.Writer, wpa_path: [:0]const u8) !Daemon {
var svlist = std.ArrayList(SysService).init(a);
errdefer {
for (svlist.items) |*sv| sv.deinit();
svlist.deinit();
}
// the order is important. when powering off, the services are shut down
// in the same order appended here.
try svlist.append(SysService.init(a, "lnd", .{ .stop_wait_sec = 600 }));
try svlist.append(SysService.init(a, "bitcoind", .{ .stop_wait_sec = 600 }));
return .{
.allocator = a,
.uiwriter = iogui,
.wpa_ctrl = try types.WpaControl.open(wpa_path),
.state = .stopped,
.services = svlist.toOwnedSlice(),
};
}
/// releases all associated resources.
/// if the daemon is not in a stopped or poweroff mode, deinit panics.
pub fn deinit(self: *Daemon) void {
self.mu.lock();
defer self.mu.unlock();
switch (self.state) {
.stopped, .poweroff => if (self.want_stop) {
@panic("deinit while stopping");
},
else => @panic("deinit while running"),
}
self.wpa_ctrl.close() catch |err| logger.err("deinit: wpa_ctrl.close: {any}", .{err});
for (self.services) |*sv| {
sv.deinit();
}
self.allocator.free(self.services);
}
/// start launches a main thread and returns immediately.
/// once started, the daemon must be eventually stop'ed to clean up resources
/// even if a poweroff sequence is launched with beginPoweroff. however, in the latter
/// case the daemon cannot be start'ed again after stop.
pub fn start(self: *Daemon) !void {
// TODO: return error if already started
self.mu.lock();
defer self.mu.unlock();
switch (self.state) {
.running => return error.AlreadyStarted,
.poweroff => return error.InPoweroffState,
.stopped => {}, // continue
}
try self.wpa_ctrl.attach();
self.main_thread = try std.Thread.spawn(.{}, mainThreadLoop, .{self});
self.state = .running;
}
/// stop blocks until all daemon threads exit, including poweroff if any.
/// once stopped, the daemon can be start'ed again unless a poweroff was initiated.
///
/// note: stop leaves system services like lnd and bitcoind running.
pub fn stop(self: *Daemon) void {
self.mu.lock();
self.quit = true;
if (self.want_stop or self.state == .stopped) {
self.mu.unlock();
return; // already in progress or stopped
}
self.want_stop = true;
self.mu.unlock(); // avoid threads deadlock
if (self.main_thread) |th| {
th.join();
self.main_thread = null;
}
// must be the last one to join because it sends a final poweroff report.
if (self.poweroff_thread) |th| {
th.join();
self.poweroff_thread = null;
}
self.mu.lock();
defer self.mu.unlock();
self.want_stop = false;
if (self.state != .poweroff) { // keep poweroff to prevent start'ing again
self.state = .stopped;
}
self.wpa_ctrl.detach() catch |err| logger.err("stop: wpa_ctrl.detach: {any}", .{err});
}
pub fn standby(_: *Daemon) !void {
pub fn standby(self: *Daemon) !void {
self.mu.lock();
defer self.mu.unlock();
switch (self.state) {
.poweroff => return error.InPoweroffState,
.running, .stopped => {}, // continue
}
try screen.backlight(.off);
}
@ -50,27 +157,72 @@ pub fn wakeup(_: *Daemon) !void {
try screen.backlight(.on);
}
/// initiates system poweroff sequence in a separate thread: shut down select
/// system services such as lnd and bitcoind, and issue "poweroff" command.
///
/// in the poweroff mode, the daemon is still running as usual and must be stop'ed.
/// however, in poweroff mode regular functionalities are disabled, such as
/// wifi scan and standby.
pub fn beginPoweroff(self: *Daemon) !void {
self.mu.lock();
defer self.mu.unlock();
if (self.state == .poweroff) {
return; // already in poweroff state
}
self.poweroff_thread = try std.Thread.spawn(.{}, poweroffThread, .{self});
self.state = .poweroff;
}
// stops all monitored services and issue poweroff command while reporting
// the progress to ngui.
fn poweroffThread(self: *Daemon) !void {
logger.info("begin powering off", .{});
screen.backlight(.on) catch |err| {
logger.err("screen.backlight(.on) during poweroff: {any}", .{err});
};
self.wpa_ctrl.detach() catch {}; // don't care because powering off anyway
// initiate shutdown of all services concurrently.
for (self.services) |*sv| {
sv.stop() catch |err| logger.err("sv stop '{s}': {any}", .{ sv.name, err });
}
self.sendPoweroffReport() catch |err| logger.err("sendPoweroffReport: {any}", .{err});
// wait each service until stopped or error.
for (self.services) |*sv| {
_ = sv.stopWait() catch {};
logger.info("{s} sv is now stopped; err={any}", .{ sv.name, sv.lastStopError() });
self.sendPoweroffReport() catch |err| logger.err("sendPoweroffReport: {any}", .{err});
}
// finally, initiate system shutdown and power it off.
var off = types.ChildProcess.init(&.{"poweroff"}, self.allocator);
const res = off.spawnAndWait();
logger.info("poweroff: {any}", .{res});
}
/// main thread entry point.
fn mainThreadLoop(self: *Daemon) !void {
try self.wpa_ctrl.attach();
defer self.wpa_ctrl.detach() catch |err| logger.err("wpa_ctrl.detach failed on exit: {any}", .{err});
while (true) {
var quit = false;
while (!quit) {
self.mainThreadLoopCycle() catch |err| logger.err("main thread loop: {any}", .{err});
time.sleep(1 * time.ns_per_s);
self.mainThreadLoopCycle();
self.mu.lock();
const do_quit = self.quit;
quit = self.want_stop;
self.mu.unlock();
if (do_quit) {
break;
}
}
}
/// run one cycle of the main thread loop iteration.
/// holds self.mu for the whole duration.
fn mainThreadLoopCycle(self: *Daemon) void {
/// unless in poweroff mode, the cycle holds self.mu for the whole duration.
fn mainThreadLoopCycle(self: *Daemon) !void {
switch (self.state) {
// poweroff mode: do nothing; handled by poweroffThread
.poweroff => {},
// normal state: running or standby
else => {
self.mu.lock();
defer self.mu.unlock();
self.readWPACtrlMsg() catch |err| logger.err("readWPACtrlMsg: {any}", .{err});
@ -81,27 +233,43 @@ fn mainThreadLoopCycle(self: *Daemon) void {
logger.err("startWifiScan: {any}", .{err});
}
}
if (self.want_report and self.report_ready) {
if (self.want_network_report and self.network_report_ready) {
if (self.sendNetworkReport()) {
self.want_report = false;
self.want_network_report = false;
} else |err| {
logger.err("sendNetworkReport: {any}", .{err});
}
}
},
}
}
fn sendPoweroffReport(self: *Daemon) !void {
var svstat = try self.allocator.alloc(comm.Message.PoweroffProgress.Service, self.services.len);
defer self.allocator.free(svstat);
for (self.services) |*sv, i| {
svstat[i] = .{
.name = sv.name,
.stopped = sv.status() == .stopped,
.err = if (sv.lastStopError()) |err| @errorName(err) else null,
};
}
const report = comm.Message{ .poweroff_progress = .{ .services = svstat } };
try comm.write(self.allocator, self.uiwriter, report);
}
/// caller must hold self.mu.
fn startWifiScan(self: *Daemon) !void {
try self.wpa_ctrl.scan();
self.wifi_scan_in_progress = true;
self.report_ready = false;
self.network_report_ready = false;
}
/// invoked when CTRL-EVENT-SCAN-RESULTS event is seen.
/// caller must hold self.mu.
fn wifiScanComplete(self: *Daemon) void {
self.wifi_scan_in_progress = false;
self.report_ready = true;
self.network_report_ready = true;
}
/// invoked when CTRL-EVENT-CONNECTED event is seen.
@ -117,15 +285,15 @@ fn wifiConnected(self: *Daemon) void {
}
}
// always send a network report when connected
self.want_report = true;
self.want_network_report = true;
}
/// invoked when CTRL-EVENT-SSID-TEMP-DISABLED event with authentication failures is seen.
/// caller must hold self.mu.
fn wifiInvalidKey(self: *Daemon) void {
self.wpa_save_config_on_connected = false;
self.want_report = true;
self.report_ready = true;
self.want_network_report = true;
self.network_report_ready = true;
}
pub const ReportNetworkStatusOpt = struct {
@ -135,10 +303,10 @@ pub const ReportNetworkStatusOpt = struct {
pub fn reportNetworkStatus(self: *Daemon, opt: ReportNetworkStatusOpt) void {
self.mu.lock();
defer self.mu.unlock();
self.want_report = true;
self.want_network_report = true;
self.want_wifi_scan = opt.scan and !self.wifi_scan_in_progress;
if (self.want_wifi_scan and self.report_ready) {
self.report_ready = false;
if (self.want_wifi_scan and self.network_report_ready) {
self.network_report_ready = false;
}
}
@ -160,7 +328,7 @@ fn connectWifiThread(self: *Daemon, ssid: []const u8, password: []const u8) void
// https://hostap.epitest.fi/wpa_supplicant/devel/ctrl_iface_page.html
// https://wiki.archlinux.org/title/WPA_supplicant
// unfortunately, this prevents main thread from looping until released.
// this prevents main thread from looping until released,
// but the following commands and expected to be pretty quick.
self.mu.lock();
defer self.mu.unlock();
@ -305,6 +473,7 @@ fn sendNetworkReport(self: *Daemon) !void {
return comm.write(self.allocator, self.uiwriter, comm.Message{ .network_report = report });
}
/// caller must hold self.mu.
fn queryWifiSSID(self: *Daemon) !?[]const u8 {
var buf: [512:0]u8 = undefined;
const resp = try self.wpa_ctrl.request("STATUS", &buf, null);
@ -320,7 +489,8 @@ fn queryWifiSSID(self: *Daemon) !?[]const u8 {
return null;
}
/// callers must free with StringList.deinit.
/// caller must hold self.mu.
/// the retuned value must free'd with StringList.deinit.
fn queryWifiScanResults(self: *Daemon) !StringList {
var buf: [8192:0]u8 = undefined; // TODO: what if isn't enough?
// first line is banner: "bssid / frequency / signal level / flags / ssid"
@ -352,7 +522,8 @@ const WifiNetworksListFilter = struct {
ssid: ?[]const u8, // ignore networks whose ssid doesn't match
};
/// caller must release results with allocator.free.
/// caller must hold self.mu.
/// the returned value must be free'd with self.allocator.
fn queryWifiNetworksList(self: *Daemon, filter: WifiNetworksListFilter) ![]u32 {
var buf: [8192:0]u8 = undefined; // TODO: is this enough?
// first line is banner: "network id / ssid / bssid / flags"
@ -407,3 +578,83 @@ const StringList = struct {
return self.l.items;
}
};
test "start-stop" {
const t = std.testing;
const pipe = try types.IoPipe.create();
defer pipe.close();
var daemon = try Daemon.init(t.allocator, pipe.writer(), "/dev/null");
try t.expect(daemon.state == .stopped);
try daemon.start();
try t.expect(daemon.state == .running);
try t.expect(daemon.wpa_ctrl.opened);
try t.expect(daemon.wpa_ctrl.attached);
daemon.stop();
try t.expect(daemon.state == .stopped);
try t.expect(!daemon.want_stop);
try t.expect(!daemon.wpa_ctrl.attached);
try t.expect(daemon.wpa_ctrl.opened);
try t.expect(daemon.main_thread == null);
try t.expect(daemon.poweroff_thread == null);
try t.expect(daemon.services.len > 0);
for (daemon.services) |*sv| {
try t.expect(!sv.stop_proc.spawned);
try t.expectEqual(SysService.Status.initial, sv.status());
}
daemon.deinit();
try t.expect(!daemon.wpa_ctrl.opened);
}
test "start-poweroff-stop" {
const t = std.testing;
const tt = @import("../test.zig");
var arena_alloc = std.heap.ArenaAllocator.init(t.allocator);
defer arena_alloc.deinit();
const arena = arena_alloc.allocator();
const pipe = try types.IoPipe.create();
var daemon = try Daemon.init(arena, pipe.writer(), "/dev/null");
defer {
daemon.deinit();
pipe.close();
}
try daemon.start();
try daemon.beginPoweroff();
daemon.stop();
try t.expect(daemon.state == .poweroff);
for (daemon.services) |*sv| {
try t.expect(sv.stop_proc.spawned);
try t.expect(sv.stop_proc.waited);
try t.expectEqual(SysService.Status.stopped, sv.status());
}
const pipe_reader = pipe.reader();
const msg1 = try comm.read(arena, pipe_reader);
try tt.expectDeepEqual(comm.Message{ .poweroff_progress = .{ .services = &.{
.{ .name = "lnd", .stopped = false, .err = null },
.{ .name = "bitcoind", .stopped = false, .err = null },
} } }, msg1);
const msg2 = try comm.read(arena, pipe_reader);
try tt.expectDeepEqual(comm.Message{ .poweroff_progress = .{ .services = &.{
.{ .name = "lnd", .stopped = true, .err = null },
.{ .name = "bitcoind", .stopped = false, .err = null },
} } }, msg2);
const msg3 = try comm.read(arena, pipe_reader);
try tt.expectDeepEqual(comm.Message{ .poweroff_progress = .{ .services = &.{
.{ .name = "lnd", .stopped = true, .err = null },
.{ .name = "bitcoind", .stopped = true, .err = null },
} } }, msg3);
// TODO: ensure "poweroff" was executed once custom runner is in a zig release;
// need custom runner to set up a global registry for child processes.
// https://github.com/ziglang/zig/pull/13411
}

@ -0,0 +1,185 @@
///! an interface to programmatically manage a system service.
///! safe for concurrent use.
const std = @import("std");
const types = @import("../types.zig");
allocator: std.mem.Allocator,
name: []const u8,
stop_wait_sec: ?u32 = null,
/// mutex guards all fields below.
mu: std.Thread.Mutex = .{},
stat: State,
stop_proc: types.ChildProcess = undefined,
stop_err: ?anyerror = null,
/// service current state.
/// the .initial value is a temporary solution until service watcher and start
/// are implemnted: at the moment, SysService can only stop services, nothing else.
pub const Status = enum(u8) {
initial, // TODO: add .running
stopping,
stopped,
};
const State = union(Status) {
initial: void,
stopping: void,
stopped: std.ChildProcess.Term,
};
const SysService = @This();
pub const InitOpts = struct {
/// how long to wait for the service to stop before SIGKILL.
/// if unspecified, default for sv is 7.
stop_wait_sec: ?u32 = null,
};
/// must deinit when done.
pub fn init(a: std.mem.Allocator, name: []const u8, opts: InitOpts) SysService {
return .{
.allocator = a,
.name = name,
.stop_wait_sec = opts.stop_wait_sec,
.stat = .initial,
};
}
pub fn deinit(_: *SysService) void {}
/// reports current state of the service.
/// note that it may incorrectly reflect the actual running service state
/// since SysService has no process watcher implementation yet.
pub fn status(self: *SysService) Status {
self.mu.lock();
defer self.mu.unlock();
return self.stat;
}
/// returns an error during last stop call, if any.
pub fn lastStopError(self: *SysService) ?anyerror {
self.mu.lock();
defer self.mu.unlock();
return self.stop_err;
}
/// launches a service stop procedure and returns immediately.
/// callers must invoke stopWait to release all resources used by the stop.
pub fn stop(self: *SysService) !void {
self.mu.lock();
defer self.mu.unlock();
self.stop_err = null;
self.spawnStop() catch |err| {
self.stop_err = err;
return err;
};
}
/// blocks until the service stopping procedure terminates.
/// an error is returned also in the case where stopping a service failed.
pub fn stopWait(self: *SysService) !void {
self.mu.lock();
defer self.mu.unlock();
self.stop_err = null;
self.spawnStop() catch |err| {
self.stop_err = err;
return err;
};
const term = self.stop_proc.wait() catch |err| {
self.stop_err = err;
return err;
};
self.stat = .{ .stopped = term };
switch (term) {
.Exited => |code| if (code != 0) {
self.stop_err = error.SysServiceBadStopCode;
},
else => {
self.stop_err = error.SysServiceBadStopTerm;
},
}
if (self.stop_err) |err| {
return err;
}
}
/// actual internal body of SysService.stop: stopWait also uses this.
/// callers must hold self.mu.
fn spawnStop(self: *SysService) !void {
switch (self.stat) {
.stopping => return, // already in progress
// intentionally let .stopped state pass through: can't see any downsides.
.initial, .stopped => {},
}
// use arena to simplify stop proc args construction.
var arena_alloc = std.heap.ArenaAllocator.init(self.allocator);
defer arena_alloc.deinit();
const arena = arena_alloc.allocator();
var argv = std.ArrayList([]const u8).init(arena);
try argv.append("sv");
if (self.stop_wait_sec) |sec| {
const s = try std.fmt.allocPrint(arena, "{d}", .{sec});
try argv.appendSlice(&.{ "-w", s });
}
try argv.appendSlice(&.{ "stop", self.name });
// can't use arena alloc since it's deinited upon return but proc needs alloc until wait'ed.
// child process dup's argv when spawned and auto-frees all resources when done (wait'ed).
self.stop_proc = types.ChildProcess.init(argv.items, self.allocator);
try self.stop_proc.spawn();
self.stat = .stopping;
}
test "stop then stopWait" {
const t = std.testing;
var sv = SysService.init(t.allocator, "testsv1", .{ .stop_wait_sec = 13 });
try t.expectEqual(Status.initial, sv.status());
try sv.stop();
try t.expectEqual(Status.stopping, sv.status());
defer sv.stop_proc.deinit(); // TestChildProcess
try t.expect(sv.stop_proc.spawned);
try t.expect(!sv.stop_proc.waited);
try t.expect(!sv.stop_proc.killed);
const cmd = try std.mem.join(t.allocator, " ", sv.stop_proc.argv);
defer t.allocator.free(cmd);
try t.expectEqualStrings("sv -w 13 stop testsv1", cmd);
try sv.stopWait();
try t.expect(sv.stop_proc.waited);
try t.expect(!sv.stop_proc.killed);
}
test "stopWait" {
const t = std.testing;
var sv = SysService.init(t.allocator, "testsv2", .{ .stop_wait_sec = 14 });
try sv.stopWait();
defer sv.stop_proc.deinit(); // TestChildProcess
try t.expect(sv.stop_proc.spawned);
try t.expect(sv.stop_proc.waited);
try t.expect(!sv.stop_proc.killed);
const cmd = try std.mem.join(t.allocator, " ", sv.stop_proc.argv);
defer t.allocator.free(cmd);
try t.expectEqualStrings("sv -w 14 stop testsv2", cmd);
}
test "stop with default wait" {
const t = std.testing;
var sv = SysService.init(t.allocator, "testsv3", .{});
try sv.stopWait();
defer sv.stop_proc.deinit(); // TestChildProcess
const cmd = try std.mem.join(t.allocator, " ", sv.stop_proc.argv);
defer t.allocator.free(cmd);
try t.expectEqualStrings("sv stop testsv3", cmd);
}

@ -1,4 +1,10 @@
const std = @import("std");
const builtin = @import("builtin");
const nif = @import("nif");
comptime {
if (!builtin.is_test) @compileError("test-only module");
}
export fn wifi_ssid_add_network(name: [*:0]const u8) void {
_ = name;
@ -13,8 +19,222 @@ export fn lv_disp_get_inactive_time(disp: *opaque {}) u32 {
return 0;
}
/// TestTimer always reports the same fixed value.
pub const TestTimer = struct {
value: u64,
started: bool = false, // true if called start
resetted: bool = false, // true if called reset
pub fn start() std.time.Timer.Error!TestTimer {
return .{ .value = 42 };
}
pub fn reset(self: *TestTimer) void {
self.resetted = true;
}
pub fn read(self: *TestTimer) u64 {
return self.value;
}
};
/// args in init are dup'ed using an allocator.
/// the caller must deinit in the end.
pub const TestChildProcess = struct {
// test hooks
spawn_callback: ?*const fn (*TestChildProcess) std.ChildProcess.SpawnError!void = null,
wait_callback: ?*const fn (*TestChildProcess) anyerror!std.ChildProcess.Term = null,
kill_callback: ?*const fn (*TestChildProcess) anyerror!std.ChildProcess.Term = null,
spawned: bool = false,
waited: bool = false,
killed: bool = false,
// original std ChildProcess init args
allocator: std.mem.Allocator,
argv: []const []const u8,
pub fn init(argv: []const []const u8, allocator: std.mem.Allocator) TestChildProcess {
var adup = allocator.alloc([]u8, argv.len) catch unreachable;
for (argv) |v, i| {
adup[i] = allocator.dupe(u8, v) catch unreachable;
}
return .{
.allocator = allocator,
.argv = adup,
};
}
pub fn deinit(self: *TestChildProcess) void {
for (self.argv) |v| self.allocator.free(v);
self.allocator.free(self.argv);
}
pub fn spawn(self: *TestChildProcess) std.ChildProcess.SpawnError!void {
defer self.spawned = true;
if (self.spawn_callback) |cb| {
return cb(self);
}
}
pub fn wait(self: *TestChildProcess) anyerror!std.ChildProcess.Term {
defer self.waited = true;
if (self.wait_callback) |cb| {
return cb(self);
}
return .{ .Exited = 0 };
}
pub fn spawnAndWait(self: *TestChildProcess) !std.ChildProcess.Term {
try self.spawn();
return self.wait();
}
pub fn kill(self: *TestChildProcess) !std.ChildProcess.Term {
defer self.killed = true;
if (self.kill_callback) |cb| {
return cb(self);
}
return .{ .Exited = 0 };
}
};
/// a nif.wpa.Control stub for tests.
pub const TestWpaControl = struct {
ctrl_path: []const u8,
opened: bool,
attached: bool = false,
scanned: bool = false,
saved: bool = false,
const Self = @This();
pub fn open(path: [:0]const u8) !Self {
return .{ .ctrl_path = path, .opened = true };
}
pub fn close(self: *Self) !void {
self.opened = false;
}
pub fn attach(self: *Self) !void {
self.attached = true;
}
pub fn detach(self: *Self) !void {
self.attached = false;
}
pub fn pending(_: Self) !bool {
return false;
}
pub fn receive(_: Self, _: [:0]u8) ![]const u8 {
return &.{};
}
pub fn scan(self: *Self) !void {
self.scanned = true;
}
pub fn saveConfig(self: *Self) !void {
self.saved = true;
}
pub fn request(_: Self, _: [:0]const u8, _: [:0]u8, _: ?nif.wpa.ReqCallback) ![]const u8 {
return &.{};
}
};
/// similar to std.testing.expectEqual but compares slices with expectEqualSlices
/// or expectEqualStrings where slice element is a u8.
pub fn expectDeepEqual(expected: anytype, actual: @TypeOf(expected)) !void {
const t = std.testing;
switch (@typeInfo(@TypeOf(actual))) {
.Pointer => |p| {
switch (p.size) {
.One => try expectDeepEqual(expected.*, actual.*),
.Slice => {
switch (@typeInfo(p.child)) {
.Pointer, .Struct, .Optional, .Union => {
var err: ?anyerror = blk: {
if (expected.len != actual.len) {
std.debug.print("expected.len = {d}, actual.len = {d}\n", .{ expected.len, actual.len });
break :blk error.ExpectDeepEqual;
}
break :blk null;
};
const n = std.math.min(expected.len, actual.len);
var i: usize = 0;
while (i < n) : (i += 1) {
expectDeepEqual(expected[i], actual[i]) catch |e| {
std.debug.print("unequal slice elements at index {d}\n", .{i});
return e;
};
}
if (err) |e| {
return e;
}
},
else => {
if (p.child == u8) {
try t.expectEqualStrings(expected, actual);
} else {
try t.expectEqualSlices(p.child, expected, actual);
}
},
}
},
else => try t.expectEqual(expected, actual),
}
},
.Struct => |st| {
inline for (st.fields) |f| {
expectDeepEqual(@field(expected, f.name), @field(actual, f.name)) catch |err| {
std.debug.print("unequal field '{s}' of struct {any}\n", .{ f.name, @TypeOf(actual) });
return err;
};
}
},
.Optional => {
if (expected) |x| {
if (actual) |v| {
try expectDeepEqual(x, v);
} else {
std.debug.print("expected {any}, found null\n", .{x});
return error.TestExpectDeepEqual;
}
} else {
if (actual) |v| {
std.debug.print("expected null, found {any}\n", .{v});
return error.TestExpectDeepEqual;
}
}
},
.Union => |u| {
if (u.tag_type == null) {
@compileError("unable to compare untagged union values");
}
const Tag = std.meta.Tag(@TypeOf(expected));
const atag = @as(Tag, actual);
try t.expectEqual(@as(Tag, expected), atag);
inline for (u.fields) |f| {
if (std.mem.eql(u8, f.name, @tagName(atag))) {
try expectDeepEqual(@field(expected, f.name), @field(actual, f.name));
return;
}
}
unreachable;
},
else => {
try t.expectEqual(expected, actual);
},
}
}
test {
_ = @import("comm.zig");
_ = @import("nd.zig");
_ = @import("nd/Daemon.zig");
_ = @import("nd/SysService.zig");
_ = @import("ngui.zig");
std.testing.refAllDecls(@This());

@ -1,17 +1,49 @@
const std = @import("std");
const builtin = @import("builtin");
const std = @import("std");
const nif = @import("nif");
const tt = @import("test.zig");
pub usingnamespace if (builtin.is_test) struct {
// stubs, mocks, overrides for testing.
pub const Timer = tt.TestTimer;
pub const ChildProcess = tt.TestChildProcess;
pub const WpaControl = tt.TestWpaControl;
} else struct {
// regular types for production code.
pub const Timer = std.time.Timer;
pub const ChildProcess = std.ChildProcess;
pub const WpaControl = nif.wpa.Control;
};
/// prefer this type over the std.ArrayList(u8) just to ensure consistency
/// and potential regressions. For example, comm module uses it for read/write.
pub const ByteArrayList = std.ArrayList(u8);
pub const Timer = if (builtin.is_test) TestTimer else std.time.Timer;
/// an OS-based I/O pipe; see man(2) pipe.
pub const IoPipe = struct {
r: std.fs.File,
w: std.fs.File,
/// TestTimer always reports the same fixed value.
pub const TestTimer = if (!builtin.is_test) @compileError("TestTimer is for tests only") else struct {
value: u64,
/// a pipe must be close'ed when done.
pub fn create() std.os.PipeError!IoPipe {
const fds = try std.os.pipe();
return .{
.r = std.fs.File{ .handle = fds[0] },
.w = std.fs.File{ .handle = fds[1] },
};
}
pub fn close(self: IoPipe) void {
self.w.close();
self.r.close();
}
pub fn reader(self: IoPipe) std.fs.File.Reader {
return self.r.reader();
}
pub fn read(self: *Timer) u64 {
return self.value;
pub fn writer(self: IoPipe) std.fs.File.Writer {
return self.w.writer();
}
};

@ -1,4 +1,5 @@
///! display and touch screen helper functions.
const builtin = @import("builtin");
const std = @import("std");
const Thread = std.Thread;
@ -36,7 +37,7 @@ pub fn sleep(wake: *const Thread.ResetEvent) void {
/// turn on or off display backlight.
pub fn backlight(onoff: enum { on, off }) !void {
const blpath = "/sys/class/backlight/rpi_backlight/bl_power";
const blpath = if (builtin.is_test) "/dev/null" else "/sys/class/backlight/rpi_backlight/bl_power";
const f = try std.fs.openFileAbsolute(blpath, .{ .mode = .write_only });
defer f.close();
const v = if (onoff == .on) "0" else "1";