From 78df4ad7ee7df7e71838e16cbbfaf7c009afa87b Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 14 Jul 2023 15:58:04 +0200 Subject: [PATCH 1/2] 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..1a67d17 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 {}", .{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"; -- 2.46.2 From 532581a24657349d0f7fbd305dcb8af954da945b Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 23 Jul 2023 12:22:24 +0200 Subject: [PATCH 2/2] ngui: display poweroff progress during system shutdown this greatly improves UX during shutdown: no need to guess at which point it's ok to unplug the power cord. the commit also includes a GUI playground. a sort of a fake daemon which sends hardcoded messages to ngui. useful for manual debugging and trying different UX scenarious. --- build.zig | 14 +++- src/ngui.zig | 89 ++++++++++++++++-------- src/test/guiplay.zig | 158 +++++++++++++++++++++++++++++++++++++++++++ src/ui/lvgl.zig | 74 ++++++++++++++++---- src/ui/poweroff.zig | 136 +++++++++++++++++++++++++++++++++++++ src/ui/ui.zig | 32 +-------- src/ui/widget.zig | 2 +- 7 files changed, 432 insertions(+), 73 deletions(-) create mode 100644 src/test/guiplay.zig create mode 100644 src/ui/poweroff.zig diff --git a/build.zig b/build.zig index 4b00c5d..33aebc7 100644 --- a/build.zig +++ b/build.zig @@ -104,7 +104,7 @@ pub fn build(b: *std.build.Builder) void { nd_build_step.dependOn(&b.addInstallArtifact(nd).step); // default build - const build_all_step = b.step("all", "build everything"); + const build_all_step = b.step("all", "build nd and ngui"); build_all_step.dependOn(ngui_build_step); build_all_step.dependOn(nd_build_step); b.default_step.dependOn(build_all_step); @@ -123,6 +123,18 @@ pub fn build(b: *std.build.Builder) void { const test_step = b.step("test", "run tests"); test_step.dependOn(&tests.step); } + + { + const guiplay = b.addExecutable("guiplay", "src/test/guiplay.zig"); + guiplay.setTarget(target); + guiplay.setBuildMode(mode); + guiplay.step.dependOn(semver_step); + guiplay.addPackagePath("comm", "src/comm.zig"); + + const guiplay_build_step = b.step("guiplay", "build GUI playground"); + guiplay_build_step.dependOn(&b.addInstallArtifact(guiplay).step); + guiplay_build_step.dependOn(ngui_build_step); + } } const DriverTarget = enum { diff --git a/src/ngui.zig b/src/ngui.zig index b2c7799..c11f052 100644 --- a/src/ngui.zig +++ b/src/ngui.zig @@ -1,5 +1,6 @@ const buildopts = @import("build_options"); const std = @import("std"); +const os = std.os; const time = std.time; const comm = @import("comm.zig"); @@ -22,25 +23,26 @@ const logger = std.log.scoped(.ngui); extern "c" fn ui_update_network_status(text: [*:0]const u8, wifi_list: ?[*:0]const u8) void; -/// global heap allocator used throughout the gui program. +/// global heap allocator used throughout the GUI program. /// TODO: thread-safety? var gpa: std.mem.Allocator = undefined; /// the mutex must be held before any call reaching into lv_xxx functions. /// all nm_xxx functions assume it is the case since they are invoked from lvgl c code. var ui_mutex: std.Thread.Mutex = .{}; + /// the program runs until quit is true. -var quit: bool = false; +/// set from sighandler or on unrecoverable comm failure with the daemon. +var want_quit: bool = false; + var state: enum { active, // normal operational mode standby, // idling alert, // draw user attention; never go standby } = .active; -/// setting wakeup brings the screen back from sleep()'ing without waiting -/// for user action. -/// can be used by comms when an alert is received from the daemon, to draw -/// user attention. +/// by setting wakeup brings the screen back from sleep()'ing without waiting for user action. +/// can be used by comms when an alert is received from the daemon, to draw user attention. /// safe for concurrent use except wakeup.reset() is UB during another thread /// wakeup.wait()'ing or timedWait'ing. var wakeup = std.Thread.ResetEvent{}; @@ -68,12 +70,13 @@ export fn nm_check_idle_time(_: *lvgl.LvTimer) void { } } -/// initiate system shutdown leading to power off. +/// tells the daemon to initiate system shutdown leading to power off. +/// once all's done, the daemon will send a SIGTERM back to ngui. export fn nm_sys_shutdown() void { - logger.info("initiating system shutdown", .{}); const msg = comm.Message.poweroff; comm.write(gpa, stdout, msg) catch |err| logger.err("nm_sys_shutdown: {any}", .{err}); - quit = true; + state = .alert; // prevent screen sleep + wakeup.set(); // wake up from standby, if any } export fn nm_tab_settings_active() void { @@ -144,17 +147,16 @@ fn updateNetworkStatus(report: comm.Message.NetworkReport) !void { } } -/// reads messages from nd; loops indefinitely until program exit -fn commThread() void { +/// reads messages from nd. +/// loops indefinitely until program exit or comm returns EOS. +fn commThreadLoop() void { while (true) { commThreadLoopCycle() catch |err| logger.err("commThreadLoopCycle: {any}", .{err}); - ui_mutex.lock(); - const do_quit = quit; - ui_mutex.unlock(); - if (do_quit) { - return; - } + + std.atomic.spinLoopHint(); + time.sleep(1 * time.ns_per_ms); } + logger.info("exiting commThreadLoop", .{}); } fn commThreadLoopCycle() !void { @@ -162,8 +164,9 @@ fn commThreadLoopCycle() !void { if (err == error.EndOfStream) { // pointless to continue running if comms is broken. // a parent/supervisor is expected to restart ngui. + logger.err("comm.read: EOS", .{}); ui_mutex.lock(); - quit = true; + want_quit = true; ui_mutex.unlock(); } return err; @@ -175,6 +178,11 @@ fn commThreadLoopCycle() !void { .network_report => |report| { updateNetworkStatus(report) catch |err| logger.err("updateNetworkStatus: {any}", .{err}); }, + .poweroff_progress => |report| { + ui_mutex.lock(); + defer ui_mutex.unlock(); + ui.poweroff.updateStatus(report) catch |err| logger.err("poweroff.updateStatus: {any}", .{err}); + }, else => logger.warn("unhandled msg tag {s}", .{@tagName(msg)}), } } @@ -218,6 +226,20 @@ fn usage(prog: []const u8) !void { , .{prog}); } +/// handles sig TERM and INT: makes the program exit. +/// +/// note: must avoid locking ui_mutex within the handler since it may lead to +/// a race and a deadlock where the sighandler is invoked while the mutex is held +/// by the UI loop because a sighandler invocation interrupts main execution flow, +/// and so the mutex would then remain locked indefinitely. +fn sighandler(sig: c_int) callconv(.C) void { + logger.info("received signal {}", .{sig}); + switch (sig) { + os.SIG.INT, os.SIG.TERM => want_quit = true, + else => {}, + } +} + /// nakamochi UI program entry point. pub fn main() anyerror!void { // main heap allocator used through the lifetime of nd @@ -240,8 +262,16 @@ pub fn main() anyerror!void { }; // start comms with daemon in a seaparate thread. - const th = try std.Thread.spawn(.{}, commThread, .{}); - th.detach(); + _ = try std.Thread.spawn(.{}, commThreadLoop, .{}); + + // set up a sigterm handler for clean exit. + const sa = os.Sigaction{ + .handler = .{ .handler = sighandler }, + .mask = os.empty_sigset, + .flags = 0, + }; + try os.sigaction(os.SIG.INT, &sa, null); + try os.sigaction(os.SIG.TERM, &sa, null); // run idle timer indefinitely _ = lvgl.createTimer(nm_check_idle_time, 2000, null) catch |err| { @@ -249,16 +279,12 @@ pub fn main() anyerror!void { }; // main UI thread; must never block unless in idle/sleep mode - // TODO: handle sigterm - while (true) { + while (!want_quit) { ui_mutex.lock(); - var till_next_ms = lvgl.loopCycle(); - const do_quit = quit; + var till_next_ms = lvgl.loopCycle(); // UI loop const do_state = state; ui_mutex.unlock(); - if (do_quit) { - return; - } + if (do_state == .standby) { // go into a screen sleep mode due to no user activity wakeup.reset(); @@ -266,6 +292,7 @@ pub fn main() anyerror!void { logger.err("comm.write standby: {any}", .{err}); }; screen.sleep(&wakeup); + // wake up due to touch screen activity or wakeup event is set logger.info("waking up from sleep", .{}); ui_mutex.lock(); @@ -279,10 +306,14 @@ pub fn main() anyerror!void { ui_mutex.unlock(); continue; } + std.atomic.spinLoopHint(); - // sleep at least 1ms - time.sleep(@max(1, till_next_ms) * time.ns_per_ms); + time.sleep(@max(1, till_next_ms) * time.ns_per_ms); // sleep at least 1ms } + logger.info("main UI loop terminated", .{}); + + // not waiting for comm thread because it is terminated at program exit here + // anyway. } test "tick" { diff --git a/src/test/guiplay.zig b/src/test/guiplay.zig new file mode 100644 index 0000000..6082574 --- /dev/null +++ b/src/test/guiplay.zig @@ -0,0 +1,158 @@ +const std = @import("std"); +const time = std.time; +const os = std.os; + +const comm = @import("comm"); + +const logger = std.log.scoped(.play); +const stderr = std.io.getStdErr().writer(); + +var ngui_proc: std.ChildProcess = undefined; +var sigquit = false; + +fn sighandler(sig: c_int) callconv(.C) void { + logger.info("received signal {} (TERM={} INT={})", .{ sig, os.SIG.TERM, os.SIG.INT }); + switch (sig) { + os.SIG.INT, os.SIG.TERM => sigquit = true, + else => {}, + } +} + +fn fatal(comptime fmt: []const u8, args: anytype) noreturn { + stderr.print(fmt, args) catch {}; + if (fmt[fmt.len - 1] != '\n') { + stderr.writeByte('\n') catch {}; + } + std.process.exit(1); +} + +const Flags = struct { + ngui_path: ?[:0]const u8 = null, + + fn deinit(self: @This(), allocator: std.mem.Allocator) void { + if (self.ngui_path) |p| allocator.free(p); + } +}; + +fn parseArgs(gpa: std.mem.Allocator) !Flags { + var flags: Flags = .{}; + + var args = try std.process.ArgIterator.initWithAllocator(gpa); + defer args.deinit(); + const prog = args.next() orelse return error.NoProgName; + + var lastarg: enum { + none, + ngui_path, + } = .none; + while (args.next()) |a| { + switch (lastarg) { + .none => {}, + .ngui_path => { + flags.ngui_path = try gpa.dupeZ(u8, a); + lastarg = .none; + continue; + }, + } + if (std.mem.eql(u8, a, "-ngui")) { + lastarg = .ngui_path; + } else { + fatal("unknown arg name {s}", .{a}); + } + } + if (lastarg != .none) { + fatal("invalid arg: {s} requires a value", .{@tagName(lastarg)}); + } + + if (flags.ngui_path == null) { + const dir = std.fs.path.dirname(prog) orelse "/"; + flags.ngui_path = try std.fs.path.joinZ(gpa, &.{ dir, "ngui" }); + } + + return flags; +} + +pub fn main() !void { + var gpa_state = std.heap.GeneralPurposeAllocator(.{}){}; + defer if (gpa_state.deinit()) { + logger.err("memory leaks detected", .{}); + }; + const gpa = gpa_state.allocator(); + const flags = try parseArgs(gpa); + defer flags.deinit(gpa); + + ngui_proc = std.ChildProcess.init(&.{flags.ngui_path.?}, gpa); + ngui_proc.stdin_behavior = .Pipe; + ngui_proc.stdout_behavior = .Pipe; + ngui_proc.stderr_behavior = .Inherit; + ngui_proc.spawn() catch |err| { + fatal("unable to start ngui: {any}", .{err}); + }; + + const sa = os.Sigaction{ + .handler = .{ .handler = sighandler }, + .mask = os.empty_sigset, + .flags = 0, + }; + try os.sigaction(os.SIG.INT, &sa, null); + try os.sigaction(os.SIG.TERM, &sa, null); + + const uireader = ngui_proc.stdout.?.reader(); + const uiwriter = ngui_proc.stdin.?.writer(); + comm.write(gpa, uiwriter, .ping) catch |err| { + logger.err("comm.write ping: {any}", .{err}); + }; + + var poweroff = false; + while (!sigquit) { + std.atomic.spinLoopHint(); + 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; + } + + const msg = comm.read(gpa, uireader) catch |err| { + logger.err("comm.read: {any}", .{err}); + continue; + }; + logger.debug("got ui msg tagged {s}", .{@tagName(msg)}); + switch (msg) { + .pong => { + logger.info("received pong from ngui", .{}); + }, + .poweroff => { + poweroff = true; + + logger.info("sending poweroff status1", .{}); + var s1: comm.Message.PoweroffProgress = .{ .services = &.{ + .{ .name = "lnd", .stopped = false, .err = null }, + .{ .name = "bitcoind", .stopped = false, .err = null }, + } }; + comm.write(gpa, uiwriter, .{ .poweroff_progress = s1 }) catch |err| logger.err("comm.write: {any}", .{err}); + + time.sleep(2 * time.ns_per_s); + logger.info("sending poweroff status2", .{}); + var s2: comm.Message.PoweroffProgress = .{ .services = &.{ + .{ .name = "lnd", .stopped = true, .err = null }, + .{ .name = "bitcoind", .stopped = false, .err = null }, + } }; + comm.write(gpa, uiwriter, .{ .poweroff_progress = s2 }) catch |err| logger.err("comm.write: {any}", .{err}); + + time.sleep(3 * time.ns_per_s); + logger.info("sending poweroff status3", .{}); + var s3: comm.Message.PoweroffProgress = .{ .services = &.{ + .{ .name = "lnd", .stopped = true, .err = null }, + .{ .name = "bitcoind", .stopped = true, .err = null }, + } }; + comm.write(gpa, uiwriter, .{ .poweroff_progress = s3 }) catch |err| logger.err("comm.write: {any}", .{err}); + }, + else => {}, + } + } + + logger.info("killing ngui", .{}); + const term = ngui_proc.kill(); + logger.info("ngui_proc.kill term: {any}", .{term}); +} diff --git a/src/ui/lvgl.zig b/src/ui/lvgl.zig index f61ff7e..77bada1 100644 --- a/src/ui/lvgl.zig +++ b/src/ui/lvgl.zig @@ -329,12 +329,17 @@ pub inline fn paletteDarken(p: Palette, l: PaletteModLevel) Color { /// represents lv_obj_t type in C. pub const LvObj = opaque { - /// deallocates all the resources used by the object, including its children. + /// deallocates all resources used by the object, including its children. /// user data pointers are untouched. pub fn destroy(self: *LvObj) void { lv_obj_del(self); } + /// deallocates all resources used by the object's children. + pub fn deleteChildren(self: *LvObj) void { + lv_obj_clean(self); + } + /// creates a new event handler where cb is called upon event with the filter code. /// to make cb called on any event, use EventCode.all filter. /// multiple event handlers are called in the same order as they were added. @@ -343,6 +348,11 @@ pub const LvObj = opaque { return lv_obj_add_event_cb(self, cb, filter, udata); } + /// sets label text to a new value. + pub fn setLabelText(self: *LvObj, text: [*:0]const u8) void { + lv_label_set_text(self, text); + } + /// sets or clears an object flag. pub fn setFlag(self: *LvObj, onoff: enum { on, off }, v: ObjFlag) void { switch (onoff) { @@ -388,11 +398,18 @@ pub const LvObj = opaque { } /// selects which side to pad in setPad func. - pub const PadSelector = enum { left, right, top, bottom, row, column }; + pub const PadSelector = enum { all, left, right, top, bottom, row, column }; /// adds a padding style to the object. pub fn setPad(self: *LvObj, v: Coord, p: PadSelector, sel: StyleSelector) void { switch (p) { + .all => { + const vsel = sel.value(); + lv_obj_set_style_pad_left(self, v, vsel); + lv_obj_set_style_pad_right(self, v, vsel); + lv_obj_set_style_pad_top(self, v, vsel); + lv_obj_set_style_pad_bottom(self, v, vsel); + }, .left => lv_obj_set_style_pad_left(self, v, sel.value()), .right => lv_obj_set_style_pad_right(self, v, sel.value()), .top => lv_obj_set_style_pad_top(self, v, sel.value()), @@ -453,6 +470,11 @@ pub const LvObj = opaque { pub fn setBackgroundColor(self: *LvObj, v: Color, sel: StyleSelector) void { lv_obj_set_style_bg_color(self, v, sel.value()); } + + /// sets the color of a text, typically a label object. + pub fn setTextColor(self: *LvObj, v: Color, sel: StyleSelector) void { + lv_obj_set_style_text_color(self, v, sel.value()); + } }; pub fn createObject(parent: *LvObj) !*LvObj { @@ -631,7 +653,7 @@ pub fn createWindow(parent: ?*LvObj, header_height: i16, title: [*:0]const u8) ! return .{ .winobj = winobj }; } -const Window = struct { +pub const Window = struct { winobj: *LvObj, pub fn content(self: Window) *LvObj { @@ -640,38 +662,58 @@ const Window = struct { }; pub const CreateLabelOpt = struct { - long_mode: enum(c.lv_label_long_mode_t) { + // LVGL defaults to .wrap + long_mode: ?enum(c.lv_label_long_mode_t) { wrap = c.LV_LABEL_LONG_WRAP, // keep the object width, wrap the too long lines and expand the object height dot = c.LV_LABEL_LONG_DOT, // keep the size and write dots at the end if the text is too long scroll = c.LV_LABEL_LONG_SCROLL, // keep the size and roll the text back and forth scroll_circular = c.LV_LABEL_LONG_SCROLL_CIRCULAR, // keep the size and roll the text circularly clip = c.LV_LABEL_LONG_CLIP, // keep the size and clip the text out of it - }, - pos: enum { - none, - centered, - }, + } = null, + pos: ?PosAlign = null, }; +/// creates a new label object. +/// the text is heap-duplicated for the lifetime of the object and free'ed automatically. pub fn createLabel(parent: *LvObj, text: [*:0]const u8, opt: CreateLabelOpt) !*LvObj { var lb = lv_label_create(parent) orelse return error.OutOfMemory; //lv_label_set_text_static(lb, text); // static doesn't work with .dot lv_label_set_text(lb, text); lv_label_set_recolor(lb, true); //lv_obj_set_height(lb, sizeContent); // default - lv_label_set_long_mode(lb, @enumToInt(opt.long_mode)); - if (opt.pos == .centered) { - lb.center(); + if (opt.long_mode) |m| { + lv_label_set_long_mode(lb, @enumToInt(m)); + } + if (opt.pos) |p| { + lb.posAlign(p, 0, 0); } return lb; } +/// formats label text using std.fmt.format and the provided buffer. +/// a label object is then created with the resulting text using createLabel. +/// the text is heap-dup'ed so no need to retain buf. see createLabel. +pub fn createLabelFmt(parent: *LvObj, buf: []u8, comptime format: []const u8, args: anytype, opt: CreateLabelOpt) !*LvObj { + const text = try std.fmt.bufPrintZ(buf, format, args); + return createLabel(parent, text, opt); +} + pub fn createButton(parent: *LvObj, label: [*:0]const u8) !*LvObj { const btn = lv_btn_create(parent) orelse return error.OutOfMemory; - _ = try createLabel(btn, label, .{ .long_mode = .dot, .pos = .centered }); + _ = try createLabel(btn, label, .{ .long_mode = .dot, .pos = .center }); return btn; } +/// creates a spinner object with hardcoded dimensions and animation speed +/// used througout the GUI. +pub fn createSpinner(parent: *LvObj) !*LvObj { + const spin = lv_spinner_create(parent, 1000, 60) orelse return error.OutOfMemory; + lv_obj_set_size(spin, 20, 20); + const ind: StyleSelector = .{ .part = .indicator }; + lv_obj_set_style_arc_width(spin, 4, ind.value()); + return spin; +} + // ========================================================================== // imports from nakamochi custom C code that extends LVGL // ========================================================================== @@ -748,12 +790,14 @@ extern fn lv_obj_add_style(obj: *LvObj, style: *LvStyle, sel: c.lv_style_selecto extern fn lv_obj_remove_style(obj: *LvObj, style: ?*LvStyle, sel: c.lv_style_selector_t) void; extern fn lv_obj_remove_style_all(obj: *LvObj) void; extern fn lv_obj_set_style_bg_color(obj: *LvObj, val: Color, sel: c.lv_style_selector_t) void; +extern fn lv_obj_set_style_text_color(obj: *LvObj, val: Color, sel: c.lv_style_selector_t) void; extern fn lv_obj_set_style_pad_left(obj: *LvObj, val: c.lv_coord_t, sel: c.lv_style_selector_t) void; extern fn lv_obj_set_style_pad_right(obj: *LvObj, val: c.lv_coord_t, sel: c.lv_style_selector_t) void; extern fn lv_obj_set_style_pad_top(obj: *LvObj, val: c.lv_coord_t, sel: c.lv_style_selector_t) void; extern fn lv_obj_set_style_pad_bottom(obj: *LvObj, val: c.lv_coord_t, sel: c.lv_style_selector_t) void; extern fn lv_obj_set_style_pad_row(obj: *LvObj, val: c.lv_coord_t, sel: c.lv_style_selector_t) void; extern fn lv_obj_set_style_pad_column(obj: *LvObj, val: c.lv_coord_t, sel: c.lv_style_selector_t) void; +extern fn lv_obj_set_style_arc_width(obj: *LvObj, val: c.lv_coord_t, sel: c.lv_style_selector_t) void; // TODO: port these to zig extern fn lv_palette_main(c.lv_palette_t) Color; @@ -766,6 +810,8 @@ extern fn lv_palette_darken(c.lv_palette_t, level: u8) Color; extern fn lv_obj_create(parent: ?*LvObj) ?*LvObj; /// deletes and deallocates an object and all its children from UI tree. extern fn lv_obj_del(obj: *LvObj) void; +/// deletes children of the obj. +extern fn lv_obj_clean(obj: *LvObj) void; extern fn lv_obj_add_flag(obj: *LvObj, v: c.lv_obj_flag_t) void; extern fn lv_obj_clear_flag(obj: *LvObj, v: c.lv_obj_flag_t) void; @@ -794,6 +840,8 @@ extern fn lv_label_set_text_static(label: *LvObj, text: [*:0]const u8) void; extern fn lv_label_set_long_mode(label: *LvObj, mode: c.lv_label_long_mode_t) void; extern fn lv_label_set_recolor(label: *LvObj, enable: bool) void; +extern fn lv_spinner_create(parent: *LvObj, speed_ms: u32, arc_deg: u32) ?*LvObj; + extern fn lv_win_create(parent: *LvObj, header_height: c.lv_coord_t) ?*LvObj; extern fn lv_win_add_title(win: *LvObj, title: [*:0]const u8) ?*LvObj; extern fn lv_win_get_content(win: *LvObj) *LvObj; diff --git a/src/ui/poweroff.zig b/src/ui/poweroff.zig new file mode 100644 index 0000000..2ff9c14 --- /dev/null +++ b/src/ui/poweroff.zig @@ -0,0 +1,136 @@ +//! poweroff workflow. +//! all functions assume ui_mutex is always locked. +const std = @import("std"); + +const comm = @import("../comm.zig"); +const lvgl = @import("lvgl.zig"); +const symbol = @import("symbol.zig"); +const widget = @import("widget.zig"); + +const logger = std.log.scoped(.ui); + +/// initiates system shutdown leading to power off. +/// defined in ngui.zig. +extern fn nm_sys_shutdown() void; + +/// called when "power off" button is pressed. +export fn nm_poweroff_btn_callback(_: *lvgl.LvEvent) void { + const proceed: [*:0]const u8 = "PROCEED"; + const abort: [*:0]const u8 = "CANCEL"; + const title = " " ++ symbol.Power ++ " SHUTDOWN"; + const text = + \\ARE YOU SURE? + \\ + \\once shut down, + \\payments cannot go through via bitcoin or lightning networks + \\until the node is powered back on. + ; + widget.modal(title, text, &.{ proceed, abort }, poweroffModalCallback) catch |err| { + logger.err("shutdown btn: modal: {any}", .{err}); + }; +} + +/// poweroff confirmation screen callback. +fn poweroffModalCallback(btn_idx: usize) void { + // proceed = 0, cancel = 1 + if (btn_idx != 0) { + return; + } + defer nm_sys_shutdown(); // initiate shutdown even if next lines fail + global_progress_win = ProgressWin.create() catch |err| { + logger.err("ProgressWin.create: {any}", .{err}); + return; + }; +} + +var global_progress_win: ?ProgressWin = null; + +/// updates the global poweroff process window with the status report. +/// the report is normally sent to GUI by the daemon. +pub fn updateStatus(report: comm.Message.PoweroffProgress) !void { + if (global_progress_win) |win| { + var all_stopped = true; + win.resetSvContainer(); + for (report.services) |sv| { + try win.addServiceStatus(sv.name, sv.stopped, sv.err); + all_stopped = all_stopped and sv.stopped; + } + if (all_stopped) { + win.status.setLabelText("powering off ..."); + } + } else { + return error.NoProgressWindow; + } +} + +/// represents a modal window in which the poweroff progress is reported until +/// the device turns off. +const ProgressWin = struct { + win: lvgl.Window, + status: *lvgl.LvObj, // text status label + svcont: *lvgl.LvObj, // services container + + /// symbol width next to the service name. this aligns all service names vertically. + /// has to be wide enough to accomodate the spinner, but not too wide + /// so that the service name is still close to the symbol. + const sym_width = 20; + + fn create() !ProgressWin { + const win = try lvgl.createWindow(null, 60, " " ++ symbol.Power ++ " SHUTDOWN"); + errdefer win.winobj.destroy(); // also deletes all children created below + const wincont = win.content(); + wincont.flexFlow(.column); + + // initial status message + const status = try lvgl.createLabel(wincont, "shutting down services. it may take up to a few minutes.", .{}); + status.setWidth(lvgl.sizePercent(100)); + // prepare a container for services status + const svcont = try lvgl.createObject(wincont); + svcont.removeBackgroundStyle(); + svcont.flexFlow(.column); + svcont.flexGrow(1); + svcont.padColumnDefault(); + svcont.setWidth(lvgl.sizePercent(100)); + + return .{ + .win = win, + .status = status, + .svcont = svcont, + }; + } + + fn resetSvContainer(self: ProgressWin) void { + self.svcont.deleteChildren(); + } + + fn addServiceStatus(self: ProgressWin, name: []const u8, stopped: bool, err: ?[]const u8) !void { + const row = try lvgl.createObject(self.svcont); + row.removeBackgroundStyle(); + row.flexFlow(.row); + row.flexAlign(.center, .center, .center); + row.padColumnDefault(); + row.setPad(10, .all, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.setHeightToContent(); + + var buf: [100]u8 = undefined; + if (err) |e| { + const sym = try lvgl.createLabelFmt(row, &buf, symbol.Warning, .{}, .{ .long_mode = .clip }); + sym.setWidth(sym_width); + sym.setTextColor(lvgl.paletteMain(.red), .{}); + const lb = try lvgl.createLabelFmt(row, &buf, "{s}: {s}", .{ name, e }, .{ .long_mode = .dot }); + lb.setTextColor(lvgl.paletteMain(.red), .{}); + lb.flexGrow(1); + } else if (stopped) { + const sym = try lvgl.createLabelFmt(row, &buf, symbol.Ok, .{}, .{ .long_mode = .clip }); + sym.setWidth(sym_width); + const lb = try lvgl.createLabelFmt(row, &buf, "{s}", .{name}, .{ .long_mode = .dot }); + lb.flexGrow(1); + } else { + const spin = try lvgl.createSpinner(row); + spin.setWidth(sym_width); + const lb = try lvgl.createLabelFmt(row, &buf, "{s}", .{name}, .{ .long_mode = .dot }); + lb.flexGrow(1); + } + } +}; diff --git a/src/ui/ui.zig b/src/ui/ui.zig index 9321211..8e3e5d3 100644 --- a/src/ui/ui.zig +++ b/src/ui/ui.zig @@ -1,15 +1,16 @@ const buildopts = @import("build_options"); const std = @import("std"); +const comm = @import("../comm.zig"); const lvgl = @import("lvgl.zig"); const drv = @import("drv.zig"); const symbol = @import("symbol.zig"); const widget = @import("widget.zig"); +pub const poweroff = @import("poweroff.zig"); const logger = std.log.scoped(.ui); extern "c" fn nm_ui_init(disp: *lvgl.LvDisp) c_int; -extern fn nm_sys_shutdown() void; pub fn init() !void { lvgl.init(); @@ -25,33 +26,6 @@ pub fn init() !void { } } -/// called when "power off" button is pressed. -export fn nm_poweroff_btn_callback(e: *lvgl.LvEvent) void { - _ = e; - const proceed: [*:0]const u8 = "PROCEED"; - const abort: [*:0]const u8 = "CANCEL"; - const title = " " ++ symbol.Power ++ " SHUTDOWN"; - const text = - \\ARE YOU SURE? - \\ - \\once shut down, - \\payments cannot go through via bitcoin or lightning networks - \\until the node is powered back on. - ; - widget.modal(title, text, &.{ proceed, abort }, poweroffModalCallback) catch |err| { - logger.err("shutdown btn: modal: {any}", .{err}); - }; -} - -fn poweroffModalCallback(btn_idx: usize) void { - // proceed = 0, cancel = 1 - if (btn_idx != 0) { - return; - } - // proceed with shutdown - nm_sys_shutdown(); -} - export fn nm_create_info_panel(parent: *lvgl.LvObj) c_int { createInfoPanel(parent) catch |err| { logger.err("createInfoPanel: {any}", .{err}); @@ -66,5 +40,5 @@ fn createInfoPanel(parent: *lvgl.LvObj) !void { var buf: [100]u8 = undefined; const sver = try std.fmt.bufPrintZ(&buf, "GUI version: {any}", .{buildopts.semver}); - _ = try lvgl.createLabel(parent, sver, .{ .long_mode = .wrap, .pos = .none }); + _ = try lvgl.createLabel(parent, sver, .{}); } diff --git a/src/ui/widget.zig b/src/ui/widget.zig index 1338d04..4729347 100644 --- a/src/ui/widget.zig +++ b/src/ui/widget.zig @@ -58,7 +58,7 @@ pub fn modal(title: [*:0]const u8, text: [*:0]const u8, btns: []const [*:0]const const wincont = win.content(); wincont.flexFlow(.column); wincont.flexAlign(.start, .center, .center); - const msg = try lvgl.createLabel(wincont, text, .{ .long_mode = .wrap, .pos = .centered }); + const msg = try lvgl.createLabel(wincont, text, .{ .pos = .center }); msg.setWidth(lvgl.displayHoriz() - 100); msg.flexGrow(1); -- 2.46.2