From c4fea2edcd39296801b06db42b6c7a39700c4560 Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 14 Jul 2023 15:58:04 +0200 Subject: [PATCH] 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. --- build.zig | 3 + lib/nif/wpa.zig | 6 +- src/comm.zig | 35 ++++- src/nd.zig | 89 ++++------- src/nd/Daemon.zig | 351 ++++++++++++++++++++++++++++++++++++------ src/nd/SysService.zig | 185 ++++++++++++++++++++++ src/test.zig | 222 +++++++++++++++++++++++++- src/types.zig | 46 +++++- src/ui/screen.zig | 3 +- 9 files changed, 820 insertions(+), 120 deletions(-) create mode 100644 src/nd/SysService.zig diff --git a/build.zig b/build.zig index 09c8c45..4b00c5d 100644 --- a/build.zig +++ b/build.zig @@ -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); diff --git a/lib/nif/wpa.zig b/lib/nif/wpa.zig index 1a9da2b..05826b0 100644 --- a/lib/nif/wpa.zig +++ b/lib/nif/wpa.zig @@ -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; diff --git a/src/comm.zig b/src/comm.zig index 1dcd33d..fe2a129 100644 --- a/src/comm.zig +++ b/src/comm.zig @@ -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; diff --git a/src/nd.zig b/src/nd.zig index fab09e9..fd53e58 100644 --- a/src/nd.zig +++ b/src/nd.zig @@ -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 {}\n", .{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}); - } - } } diff --git a/src/nd/Daemon.zig b/src/nd/Daemon.zig index 6bd6268..719fbda 100644 --- a/src/nd/Daemon.zig +++ b/src/nd/Daemon.zig @@ -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; - self.mu.unlock(); + 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,58 +157,119 @@ 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 { - self.mu.lock(); - defer self.mu.unlock(); - self.readWPACtrlMsg() catch |err| logger.err("readWPACtrlMsg: {any}", .{err}); - if (self.want_wifi_scan) { - if (self.startWifiScan()) { - self.want_wifi_scan = false; - } else |err| { - logger.err("startWifiScan: {any}", .{err}); - } +/// 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}); + if (self.want_wifi_scan) { + if (self.startWifiScan()) { + self.want_wifi_scan = false; + } else |err| { + logger.err("startWifiScan: {any}", .{err}); + } + } + if (self.want_network_report and self.network_report_ready) { + if (self.sendNetworkReport()) { + self.want_network_report = false; + } else |err| { + logger.err("sendNetworkReport: {any}", .{err}); + } + } + }, } - if (self.want_report and self.report_ready) { - if (self.sendNetworkReport()) { - self.want_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 +} diff --git a/src/nd/SysService.zig b/src/nd/SysService.zig new file mode 100644 index 0000000..c902556 --- /dev/null +++ b/src/nd/SysService.zig @@ -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); +} diff --git a/src/test.zig b/src/test.zig index 7ab39c5..d9e3823 100644 --- a/src/test.zig +++ b/src/test.zig @@ -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()); diff --git a/src/types.zig b/src/types.zig index 3330a83..2d1fef3 100644 --- a/src/types.zig +++ b/src/types.zig @@ -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(); } }; diff --git a/src/ui/screen.zig b/src/ui/screen.zig index c5bf992..b48a74c 100644 --- a/src/ui/screen.zig +++ b/src/ui/screen.zig @@ -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";