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";