From 63eb27bf181c8da9c6c9d287907cddc9078d09fe Mon Sep 17 00:00:00 2001 From: alex Date: Sat, 29 Jul 2023 12:28:53 +0200 Subject: [PATCH] nd,ngui: make program termination reliable on SIGTERM most troubles were due to blocking nature of child process pipe descriptions which made comm.read loops hang in various places under certain conditions. this commit moves all read/writes in separate threads to be able to always watch for sig TERM and INT, and ensures child process pipes are closed early allowing comm.read loops to terminate. --- src/comm.zig | 17 +- src/nd.zig | 142 ++++-------- src/nd/Daemon.zig | 539 ++++++++++++++++++------------------------- src/nd/network.zig | 183 +++++++++++++++ src/ngui.zig | 161 ++++++------- src/test.zig | 22 ++ src/test/guiplay.zig | 94 ++++---- src/types.zig | 32 +++ 8 files changed, 655 insertions(+), 535 deletions(-) create mode 100644 src/nd/network.zig diff --git a/src/comm.zig b/src/comm.zig index fe2a129..ea90080 100644 --- a/src/comm.zig +++ b/src/comm.zig @@ -1,12 +1,14 @@ -///! daemon/gui communication. -///! the protocol is a simple TLV construct: MessageTag(u16), length(u64), json-marshalled Message; -///! little endian. +//! daemon/gui communication. +//! the protocol is a simple TLV construct: MessageTag(u16), length(u64), json-marshalled Message; +//! little endian. const std = @import("std"); const json = std.json; const mem = std.mem; const ByteArrayList = @import("types.zig").ByteArrayList; +const logger = std.log.scoped(.comm); + /// common errors returned by read/write functions. pub const Error = error{ CommReadInvalidTag, @@ -71,12 +73,13 @@ pub const MessageTag = enum(u16) { }; /// reads and parses a single message from the input stream reader. -/// callers must deallocate resources with free when done. +/// propagates reader errors as is. for example, a closed reader returns +/// error.EndOfStream. +/// +/// callers must deallocate resources with Message.free when done. pub fn read(allocator: mem.Allocator, reader: anytype) !Message { // alternative is @intToEnum(reader.ReadIntLittle(u16)) but it may panic. - const tag = reader.readEnum(MessageTag, .Little) catch { - return Error.CommReadInvalidTag; - }; + const tag = try reader.readEnum(MessageTag, .Little); const len = try reader.readIntLittle(u64); if (len == 0) { return switch (tag) { diff --git a/src/nd.zig b/src/nd.zig index 1a67d17..a1b1530 100644 --- a/src/nd.zig +++ b/src/nd.zig @@ -28,21 +28,11 @@ fn usage(prog: []const u8) !void { , .{prog}); } -/// prints messages in the same way std.fmt.format does and exits the process -/// with a non-zero code. -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); -} - -/// nd program args. see usage. +/// nd program flags. see usage. const NdArgs = struct { - gui: ?[:0]const u8 = null, // = "ngui", - gui_user: ?[:0]const u8 = null, // u8 = "uiuser", - wpa: ?[:0]const u8 = null, // = "/var/run/wpa_supplicant/wlan0", + gui: ?[:0]const u8 = null, + gui_user: ?[:0]const u8 = null, + wpa: ?[:0]const u8 = null, fn deinit(self: @This(), allocator: std.mem.Allocator) void { if (self.gui) |p| allocator.free(p); @@ -97,29 +87,38 @@ fn parseArgs(gpa: std.mem.Allocator) !NdArgs { } else if (std.mem.eql(u8, a, "-wpa")) { lastarg = .wpa; } else { - fatal("unknown arg name {s}", .{a}); + logger.err("unknown arg name {s}", .{a}); + return error.UnknownArgName; } } if (lastarg != .none) { - fatal("invalid arg: {s} requires a value", .{@tagName(lastarg)}); + logger.err("invalid arg: {s} requires a value", .{@tagName(lastarg)}); + return error.MissinArgValue; + } + if (flags.gui == null) { + logger.err("missing -gui arg", .{}); + return error.MissingGuiFlag; + } + if (flags.gui_user == null) { + logger.err("missing -gui-user arg", .{}); + return error.MissinGuiUserFlag; + } + if (flags.wpa == null) { + logger.err("missing -wpa arg", .{}); + return error.MissingWpaFlag; } - if (flags.gui == null) fatal("missing -gui arg", .{}); - if (flags.gui_user == null) fatal("missing -gui-user arg", .{}); - if (flags.wpa == null) fatal("missing -wpa arg", .{}); return flags; } -/// 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; +/// sigquit tells nd to exit. +var sigquit: std.Thread.ResetEvent = .{}; fn sighandler(sig: c_int) callconv(.C) void { logger.info("received signal {}", .{sig}); switch (sig) { - os.SIG.INT, os.SIG.TERM => sigquit = true, + os.SIG.INT, os.SIG.TERM => sigquit.set(), else => {}, } } @@ -141,7 +140,8 @@ pub fn main() !void { screen.backlight(.on) catch |err| logger.err("backlight: {any}", .{err}); // start ngui, unless -nogui mode - var ngui = std.ChildProcess.init(&.{args.gui.?}, gpa); + const gui_path = args.gui.?; // guaranteed to be non-null + var ngui = std.ChildProcess.init(&.{gui_path}, gpa); ngui.stdin_behavior = .Pipe; ngui.stdout_behavior = .Pipe; ngui.stderr_behavior = .Inherit; @@ -158,17 +158,29 @@ 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| { + logger.err("unable to start ngui at path {s}", .{gui_path}); + return err; + }; + // if the daemon fails to start and its process exits, ngui may hang forever + // preventing system services monitoring to detect a failure and restart nd. + // so, make sure to kill the ngui child process on fatal failures. + errdefer _ = ngui.kill() catch {}; - // TODO: thread-safety, esp. uiwriter + // the i/o is closed as soon as ngui child process terminates. + // note: read(2) indicates file destriptor i/o is atomic linux since 3.14. const uireader = ngui.stdout.?.reader(); const uiwriter = ngui.stdin.?.writer(); - // send UI a ping as the first thing to make sure pipes are working. - // https://git.qcode.ch/nakamochi/ndg/issues/16 + // send UI a ping right away to make sure pipes are working, crash otherwise. comm.write(gpa, uiwriter, .ping) catch |err| { logger.err("comm.write ping: {any}", .{err}); + return err; }; + var nd = try Daemon.init(gpa, uireader, uiwriter, args.wpa.?); + defer nd.deinit(); + try nd.start(); + // graceful shutdown; see sigaction(2) const sa = os.Sigaction{ .handler = .{ .handler = sighandler }, @@ -177,71 +189,13 @@ pub fn main() !void { }; try os.sigaction(os.SIG.INT, &sa, null); try os.sigaction(os.SIG.TERM, &sa, null); + sigquit.wait(); - 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 }); - - var poweroff = false; - // 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| { - 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; - nd.beginPoweroff() catch |err| { - logger.err("beginPoweroff: {any}", .{err}); - poweroff = false; - }; - }, - .get_network_report => |req| { - nd.reportNetworkStatus(.{ .scan = req.scan }); - }, - .wifi_connect => |req| { - nd.startConnectWifi(req.ssid, req.password) catch |err| { - logger.err("startConnectWifi: {any}", .{err}); - }; - }, - .standby => { - logger.info("entering standby mode", .{}); - nd.standby() catch |err| { - logger.err("nd.standby: {any}", .{err}); - }; - }, - .wakeup => { - logger.info("wakeup from standby", .{}); - nd.wakeup() catch |err| { - logger.err("nd.wakeup: {any}", .{err}); - }; - }, - else => logger.warn("unhandled msg tag {s}", .{@tagName(msg)}), - } - comm.free(gpa, msg); - } - - // 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}); + // reached here due to sig TERM or INT. + // tell deamon to terminate threads. nd.stop(); + // once ngui exits, it'll close uireader/writer i/o from child proc + // which lets the daemon's wait() to return. + _ = ngui.kill() catch |err| logger.err("ngui.kill: {any}", .{err}); + nd.wait(); } diff --git a/src/nd/Daemon.zig b/src/nd/Daemon.zig index 719fbda..b74c2dd 100644 --- a/src/nd/Daemon.zig +++ b/src/nd/Daemon.zig @@ -1,30 +1,30 @@ //! 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, -//! }; +//! var nd = Daemon.init(gpa, ngui_io_reader, ngui_io_writer, "/run/wpa_suppl/wlan0"); +//! defer nd.deinit(); //! try nd.start(); +//! // wait for sigterm... +//! nd.stop(); +//! // terminate ngui proc... +//! nd.wait(); +//! +const builtin = @import("builtin"); const std = @import("std"); const mem = std.mem; const time = std.time; -const nif = @import("nif"); - const comm = @import("../comm.zig"); +const network = @import("network.zig"); const screen = @import("../ui/screen.zig"); const types = @import("../types.zig"); const SysService = @import("SysService.zig"); -const logger = std.log.scoped(.netmon); +const logger = std.log.scoped(.daemon); allocator: mem.Allocator, +uireader: std.fs.File.Reader, // ngui stdout uiwriter: std.fs.File.Writer, // ngui stdin wpa_ctrl: types.WpaControl, // guarded by mu once start'ed @@ -35,17 +35,20 @@ mu: std.Thread.Mutex = .{}, state: enum { stopped, running, + standby, poweroff, }, main_thread: ?std.Thread = null, +comm_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, +// network flags +want_network_report: bool, // start gathering network status and send out as soon as ready +want_wifi_scan: bool, // initiate wifi scan at the next loop cycle +network_report_ready: bool, // indicates whether the network status is ready to be sent wifi_scan_in_progress: bool = false, -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. @@ -55,8 +58,10 @@ services: []SysService = &.{}, const Daemon = @This(); +/// initializes a daemon instance using the provided GUI stdout reader and stdin writer, +/// and a filesystem path to WPA control socket. /// callers must deinit when done. -pub fn init(a: std.mem.Allocator, iogui: std.fs.File.Writer, wpa_path: [:0]const u8) !Daemon { +pub fn init(a: std.mem.Allocator, r: std.fs.File.Reader, w: std.fs.File.Writer, wpa: [:0]const u8) !Daemon { var svlist = std.ArrayList(SysService).init(a); errdefer { for (svlist.items) |*sv| sv.deinit(); @@ -68,24 +73,21 @@ pub fn init(a: std.mem.Allocator, iogui: std.fs.File.Writer, wpa_path: [:0]const try svlist.append(SysService.init(a, "bitcoind", .{ .stop_wait_sec = 600 })); return .{ .allocator = a, - .uiwriter = iogui, - .wpa_ctrl = try types.WpaControl.open(wpa_path), + .uireader = r, + .uiwriter = w, + .wpa_ctrl = try types.WpaControl.open(wpa), .state = .stopped, .services = svlist.toOwnedSlice(), + // send a network report right at start without wifi scan to make it faster. + .want_network_report = true, + .want_wifi_scan = false, + .network_report_ready = true, }; } /// releases all associated resources. -/// if the daemon is not in a stopped or poweroff mode, deinit panics. +/// the daemon must be stop'ed and wait'ed before deiniting. 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(); @@ -93,95 +95,124 @@ pub fn deinit(self: *Daemon) void { 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. +/// start launches daemon threads and returns immediately. +/// once started, the daemon must be eventually stop'ed and wait'ed to clean up +/// resources even if a poweroff sequence is initiated with beginPoweroff. pub fn start(self: *Daemon) !void { self.mu.lock(); defer self.mu.unlock(); switch (self.state) { - .running => return error.AlreadyStarted, - .poweroff => return error.InPoweroffState, .stopped => {}, // continue + .poweroff => return error.InPoweroffState, + else => return error.AlreadyStarted, } try self.wpa_ctrl.attach(); + errdefer { + self.wpa_ctrl.detach() catch {}; + self.want_stop = true; + } + self.main_thread = try std.Thread.spawn(.{}, mainThreadLoop, .{self}); + self.comm_thread = try std.Thread.spawn(.{}, commThreadLoop, .{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. +/// tells the daemon to stop threads to prepare for termination. +/// stop returns immediately. +/// callers must `wait` to release all resources. pub fn stop(self: *Daemon) void { self.mu.lock(); - if (self.want_stop or self.state == .stopped) { - self.mu.unlock(); - return; // already in progress or stopped - } + defer self.mu.unlock(); self.want_stop = true; - self.mu.unlock(); // avoid threads deadlock +} +/// blocks and waits for all threads to terminate. the daemon instance cannot +/// be start'ed afterwards. +/// +/// note that in order for wait to return, the GUI I/O reader provided at init +/// must be closed. +pub fn wait(self: *Daemon) void { if (self.main_thread) |th| { th.join(); self.main_thread = null; } + if (self.comm_thread) |th| { + th.join(); + self.comm_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.wpa_ctrl.detach() catch |err| logger.err("wait: wpa_ctrl.detach: {any}", .{err}); 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}); + self.state = .stopped; } -pub fn standby(self: *Daemon) !void { +/// tells the daemon to go into a standby mode, typically due to user inactivity. +fn standby(self: *Daemon) !void { self.mu.lock(); defer self.mu.unlock(); switch (self.state) { - .poweroff => return error.InPoweroffState, - .running, .stopped => {}, // continue + .standby => {}, + .stopped, .poweroff => return error.InvalidState, + .running => { + try screen.backlight(.off); + self.state = .standby; + }, } - try screen.backlight(.off); } -pub fn wakeup(_: *Daemon) !void { - try screen.backlight(.on); +/// tells the daemon to return from standby, typically due to user interaction. +fn wakeup(self: *Daemon) !void { + self.mu.lock(); + defer self.mu.unlock(); + switch (self.state) { + .running => {}, + .stopped, .poweroff => return error.InvalidState, + .standby => { + try screen.backlight(.on); + self.state = .running; + }, + } } /// 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 { +/// beingPoweroff also makes other threads exit but callers must still call `wait` +/// to make sure poweroff sequence is complete. +fn beginPoweroff(self: *Daemon) !void { self.mu.lock(); defer self.mu.unlock(); - if (self.state == .poweroff) { - return; // already in poweroff state + switch (self.state) { + .poweroff => {}, // already in poweroff mode + .stopped => return error.InvalidState, + .running, .standby => { + self.poweroff_thread = try std.Thread.spawn(.{}, poweroffThread, .{self}); + self.state = .poweroff; + self.want_stop = true; + }, } - - 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 { +/// set when poweroff_thread starts. available in tests only. +var test_poweroff_started = if (builtin.is_test) std.Thread.ResetEvent{} else {}; + +/// the poweroff thread entry point: stops all monitored services and issues poweroff +/// command while reporting the progress to ngui. +/// exits after issuing "poweroff" command. +fn poweroffThread(self: *Daemon) void { + if (builtin.is_test) { + test_poweroff_started.set(); + } 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| { @@ -202,48 +233,110 @@ fn poweroffThread(self: *Daemon) !void { logger.info("poweroff: {any}", .{res}); } -/// main thread entry point. -fn mainThreadLoop(self: *Daemon) !void { +/// main thread entry point: watches for want_xxx flags and monitors network. +/// exits when want_stop is true. +fn mainThreadLoop(self: *Daemon) void { var quit = false; while (!quit) { self.mainThreadLoopCycle() catch |err| logger.err("main thread loop: {any}", .{err}); + std.atomic.spinLoopHint(); time.sleep(1 * time.ns_per_s); self.mu.lock(); quit = self.want_stop; self.mu.unlock(); } + logger.info("exiting main thread loop", .{}); } -/// run one cycle of the main thread loop iteration. -/// unless in poweroff mode, the cycle holds self.mu for the whole duration. +/// runs one cycle of the main thread loop iteration. +/// 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 (network.sendReport(self.allocator, &self.wpa_ctrl, self.uiwriter)) { + self.want_network_report = false; + } else |err| { + logger.err("network.sendReport: {any}", .{err}); + } + } +} + +/// comm thread entry point: reads messages sent from ngui and acts accordinly. +/// exits when want_stop is true or comm reader is closed. +/// note: the thread might not exit immediately on want_stop because comm.read +/// is blocking. +fn commThreadLoop(self: *Daemon) void { + var quit = false; + loop: while (!quit) { + std.atomic.spinLoopHint(); + time.sleep(100 * time.ns_per_ms); + + const msg = comm.read(self.allocator, self.uireader) catch |err| { 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_stop) { + break :loop; // pipe is most likely already closed } - 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}); - } + switch (self.state) { + .stopped, .poweroff => break :loop, + .running, .standby => { + logger.err("commThreadLoop: {any}", .{err}); + if (err == error.EndOfStream) { + // pointless to continue running if comms I/O is broken. + self.want_stop = true; + break :loop; + } + continue; + }, } - }, + }; + + logger.debug("got msg: {s}", .{@tagName(msg)}); + switch (msg) { + .pong => { + logger.info("received pong from ngui", .{}); + }, + .poweroff => { + self.beginPoweroff() catch |err| logger.err("beginPoweroff: {any}", .{err}); + }, + .get_network_report => |req| { + self.reportNetworkStatus(.{ .scan = req.scan }); + }, + .wifi_connect => |req| { + self.startConnectWifi(req.ssid, req.password) catch |err| { + logger.err("startConnectWifi: {any}", .{err}); + }; + }, + .standby => { + logger.info("entering standby mode", .{}); + self.standby() catch |err| logger.err("nd.standby: {any}", .{err}); + }, + .wakeup => { + logger.info("wakeup from standby", .{}); + self.wakeup() catch |err| logger.err("nd.wakeup: {any}", .{err}); + }, + else => logger.warn("unhandled msg tag {s}", .{@tagName(msg)}), + } + comm.free(self.allocator, msg); + + self.mu.lock(); + quit = self.want_stop; + self.mu.unlock(); } + logger.info("exiting comm thread loop", .{}); } +/// sends poweroff progress to uiwriter in comm.Message.PoweroffProgress format. fn sendPoweroffReport(self: *Daemon) !void { var svstat = try self.allocator.alloc(comm.Message.PoweroffProgress.Service, self.services.len); defer self.allocator.free(svstat); @@ -289,18 +382,20 @@ fn wifiConnected(self: *Daemon) void { } /// invoked when CTRL-EVENT-SSID-TEMP-DISABLED event with authentication failures is seen. -/// caller must hold self.mu. +/// callers must hold self.mu. fn wifiInvalidKey(self: *Daemon) void { self.wpa_save_config_on_connected = false; self.want_network_report = true; self.network_report_ready = true; } -pub const ReportNetworkStatusOpt = struct { +const ReportNetworkStatusOpt = struct { scan: bool, }; -pub fn reportNetworkStatus(self: *Daemon, opt: ReportNetworkStatusOpt) void { +/// tells the daemon to start preparing network status report, including a wifi +/// scan as an option. +fn reportNetworkStatus(self: *Daemon, opt: ReportNetworkStatusOpt) void { self.mu.lock(); defer self.mu.unlock(); self.want_network_report = true; @@ -310,7 +405,8 @@ pub fn reportNetworkStatus(self: *Daemon, opt: ReportNetworkStatusOpt) void { } } -pub fn startConnectWifi(self: *Daemon, ssid: []const u8, password: []const u8) !void { +/// initiates wifi connection procedure in a separate thread +fn startConnectWifi(self: *Daemon, ssid: []const u8, password: []const u8) !void { if (ssid.len == 0) { return error.ConnectWifiEmptySSID; } @@ -320,21 +416,24 @@ pub fn startConnectWifi(self: *Daemon, ssid: []const u8, password: []const u8) ! th.detach(); } +/// the wifi connection procedure thread entry point. +/// holds self.mu for the whole duration. however the thread lifetime is expected +/// to be short since all it does is issuing commands to self.wpa_ctrl. +/// +/// the thread owns ssid and password args, and frees them at exit. fn connectWifiThread(self: *Daemon, ssid: []const u8, password: []const u8) void { + self.mu.lock(); defer { + self.mu.unlock(); self.allocator.free(ssid); self.allocator.free(password); } + // https://hostap.epitest.fi/wpa_supplicant/devel/ctrl_iface_page.html // https://wiki.archlinux.org/title/WPA_supplicant - // 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(); - - const id = self.addWifiNetwork(ssid, password) catch |err| { - logger.err("addWifiNetwork: {any}; exiting", .{err}); + const id = network.addWifi(self.allocator, &self.wpa_ctrl, ssid, password) catch |err| { + logger.err("addWifi: {any}; exiting", .{err}); return; }; // SELECT_NETWORK - this disables others @@ -353,51 +452,8 @@ fn connectWifiThread(self: *Daemon, ssid: []const u8, password: []const u8) void self.wpa_save_config_on_connected = true; } -/// adds a new network and configures its parameters. -/// caller must hold self.mu. -fn addWifiNetwork(self: *Daemon, ssid: []const u8, password: []const u8) !u32 { - // - ADD_NETWORK -> get id and set parameters - // - SET_NETWORK ssid "ssid" - // - if password: - // SET_NETWORK psk "password" - // else: - // SET_NETWORK key_mgmt NONE - const newWifiId = try self.wpa_ctrl.addNetwork(); - errdefer self.wpa_ctrl.removeNetwork(newWifiId) catch |err| { - logger.err("addWifiNetwork cleanup: {any}", .{err}); - }; - var buf: [128:0]u8 = undefined; - // TODO: convert ssid to hex string, to support special characters - const ssidZ = try std.fmt.bufPrintZ(&buf, "\"{s}\"", .{ssid}); - try self.wpa_ctrl.setNetworkParam(newWifiId, "ssid", ssidZ); - if (password.len > 0) { - // TODO: switch to wpa_passphrase - const v = try std.fmt.bufPrintZ(&buf, "\"{s}\"", .{password}); - try self.wpa_ctrl.setNetworkParam(newWifiId, "psk", v); - } else { - try self.wpa_ctrl.setNetworkParam(newWifiId, "key_mgmt", "NONE"); - } - - // - LIST_NETWORKS: network id / ssid / bssid / flags - // - for each matching ssid unless it's newly created: REMOVE_NETWORK - if (self.queryWifiNetworksList(.{ .ssid = ssid })) |res| { - defer self.allocator.free(res); - for (res) |id| { - if (id == newWifiId) { - continue; - } - self.wpa_ctrl.removeNetwork(id) catch |err| { - logger.err("wpa_ctrl.removeNetwork({}): {any}", .{ id, err }); - }; - } - } else |err| { - logger.err("queryWifiNetworksList({s}): {any}; won't remove existing, if any", .{ ssid, err }); - } - - return newWifiId; -} - -/// caller must hold self.mu. +/// reads all available messages from self.wpa_ctrl and acts accordingly. +/// callers must hold self.mu. fn readWPACtrlMsg(self: *Daemon) !void { var buf: [512:0]u8 = undefined; while (try self.wpa_ctrl.pending()) { @@ -428,177 +484,31 @@ fn readWPACtrlMsg(self: *Daemon) !void { } } -/// report network status to ngui. -/// caller must hold self.mu. -fn sendNetworkReport(self: *Daemon) !void { - var report = comm.Message.NetworkReport{ - .ipaddrs = undefined, - .wifi_ssid = null, - .wifi_scan_networks = undefined, - }; - - // fetch all public IP addresses using getifaddrs - const pubaddr = try nif.pubAddresses(self.allocator, null); - defer self.allocator.free(pubaddr); - //var addrs = std.ArrayList([]).init(t.allocator); - var ipaddrs = try self.allocator.alloc([]const u8, pubaddr.len); - for (pubaddr) |a, i| { - ipaddrs[i] = try std.fmt.allocPrint(self.allocator, "{s}", .{a}); - } - defer { - for (ipaddrs) |a| self.allocator.free(a); - self.allocator.free(ipaddrs); - } - report.ipaddrs = ipaddrs; - - // get currently connected SSID, if any, from WPA ctrl - const ssid = self.queryWifiSSID() catch |err| blk: { - logger.err("queryWifiSsid: {any}", .{err}); - break :blk null; - }; - defer if (ssid) |v| self.allocator.free(v); - report.wifi_ssid = ssid; - - // fetch available wifi networks from scan results using WPA ctrl - var wifi_networks: ?StringList = if (self.queryWifiScanResults()) |v| v else |err| blk: { - logger.err("queryWifiScanResults: {any}", .{err}); - break :blk null; - }; - defer if (wifi_networks) |*list| list.deinit(); - if (wifi_networks) |list| { - report.wifi_scan_networks = list.items(); - } - - // report everything back to ngui - 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); - const ssid = "ssid="; - var it = mem.tokenize(u8, resp, "\n"); - while (it.next()) |line| { - if (mem.startsWith(u8, line, ssid)) { - // TODO: check line.len vs ssid.len - const v = try self.allocator.dupe(u8, line[ssid.len..]); - return v; - } - } - return null; -} - -/// 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" - const resp = try self.wpa_ctrl.request("SCAN_RESULTS", &buf, null); - var it = mem.tokenize(u8, resp, "\n"); - if (it.next() == null) { - return error.MissingWifiScanHeader; - } - - var seen = std.BufSet.init(self.allocator); - defer seen.deinit(); - var list = StringList.init(self.allocator); - errdefer list.deinit(); - while (it.next()) |line| { - // TODO: wpactrl's text protocol won't work for names with control characters - if (mem.lastIndexOfScalar(u8, line, '\t')) |i| { - const s = mem.trim(u8, line[i..], "\t\n"); - if (s.len == 0 or seen.contains(s)) { - continue; - } - try seen.insert(s); - try list.append(s); - } - } - return list; -} - -const WifiNetworksListFilter = struct { - ssid: ?[]const u8, // ignore networks whose ssid doesn't match -}; - -/// 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" - const resp = try self.wpa_ctrl.request("LIST_NETWORKS", &buf, null); - var it = mem.tokenize(u8, resp, "\n"); - if (it.next() == null) { - return error.MissingWifiNetworksListHeader; - } - - var list = std.ArrayList(u32).init(self.allocator); - while (it.next()) |line| { - var cols = mem.tokenize(u8, line, "\t"); - const id_str = cols.next() orelse continue; // bad line format? - const ssid = cols.next() orelse continue; // bad line format? - const id = std.fmt.parseUnsigned(u32, id_str, 10) catch continue; // skip bad line - if (filter.ssid != null and !mem.eql(u8, filter.ssid.?, ssid)) { - continue; - } - list.append(id) catch {}; // grab anything we can - } - return list.toOwnedSlice(); -} - -// TODO: turns this into a UniqStringList backed by StringArrayHashMap; also see std.BufSet -const StringList = struct { - l: std.ArrayList([]const u8), - allocator: mem.Allocator, - - const Self = @This(); - - pub fn init(allocator: mem.Allocator) Self { - return Self{ - .l = std.ArrayList([]const u8).init(allocator), - .allocator = allocator, - }; - } - - pub fn deinit(self: *Self) void { - for (self.l.items) |a| { - self.allocator.free(a); - } - self.l.deinit(); - } - - pub fn append(self: *Self, s: []const u8) !void { - const item = try self.allocator.dupe(u8, s); - errdefer self.allocator.free(item); - try self.l.append(item); - } - - pub fn items(self: Self) []const []const u8 { - 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"); + var daemon = try Daemon.init(t.allocator, pipe.reader(), pipe.writer(), "/dev/null"); + daemon.want_network_report = false; try t.expect(daemon.state == .stopped); try daemon.start(); try t.expect(daemon.state == .running); + try t.expect(daemon.main_thread != null); + try t.expect(daemon.comm_thread != null); + try t.expect(daemon.poweroff_thread == null); try t.expect(daemon.wpa_ctrl.opened); try t.expect(daemon.wpa_ctrl.attached); daemon.stop(); + pipe.close(); + daemon.wait(); 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.comm_thread == null); try t.expect(daemon.poweroff_thread == null); + try t.expect(!daemon.wpa_ctrl.attached); + try t.expect(daemon.wpa_ctrl.opened); try t.expect(daemon.services.len > 0); for (daemon.services) |*sv| { @@ -610,7 +520,7 @@ test "start-stop" { try t.expect(!daemon.wpa_ctrl.opened); } -test "start-poweroff-stop" { +test "start-poweroff" { const t = std.testing; const tt = @import("../test.zig"); @@ -618,37 +528,44 @@ test "start-poweroff-stop" { 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"); + const gui_stdin = try types.IoPipe.create(); + const gui_stdout = try types.IoPipe.create(); + const gui_reader = gui_stdin.reader(); + var daemon = try Daemon.init(arena, gui_stdout.reader(), gui_stdin.writer(), "/dev/null"); + daemon.want_network_report = false; defer { daemon.deinit(); - pipe.close(); + gui_stdin.close(); } try daemon.start(); - try daemon.beginPoweroff(); - daemon.stop(); + try comm.write(arena, gui_stdout.writer(), comm.Message.poweroff); + try test_poweroff_started.timedWait(2 * time.ns_per_s); try t.expect(daemon.state == .poweroff); + + gui_stdout.close(); + daemon.wait(); + try t.expect(daemon.state == .stopped); + try t.expect(daemon.poweroff_thread == null); 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); + const msg1 = try comm.read(arena, gui_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); + const msg2 = try comm.read(arena, gui_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); + const msg3 = try comm.read(arena, gui_reader); try tt.expectDeepEqual(comm.Message{ .poweroff_progress = .{ .services = &.{ .{ .name = "lnd", .stopped = true, .err = null }, .{ .name = "bitcoind", .stopped = true, .err = null }, diff --git a/src/nd/network.zig b/src/nd/network.zig new file mode 100644 index 0000000..cfe5e54 --- /dev/null +++ b/src/nd/network.zig @@ -0,0 +1,183 @@ +//! network utility functions. +//! unsafe for concurrent use: callers must implement a fence mechanism +//! to allow only a single function execution concurrently when called with +//! the same WPA control socket, and possibly i/o writer or allocator unless +//! those already provide synchronization. + +const std = @import("std"); +const mem = std.mem; + +const nif = @import("nif"); +const comm = @import("../comm.zig"); +const types = @import("../types.zig"); + +const logger = std.log.scoped(.network); + +/// creates a new network using wpa_ctrl and configures its parameters. +/// returns an ID of the new wifi. +/// +/// if password is blank, the key management config is set to NONE. +/// note: only cleartext passwords are supported at the moment. +pub fn addWifi(gpa: mem.Allocator, wpa_ctrl: *types.WpaControl, ssid: []const u8, password: []const u8) !u32 { + // - ADD_NETWORK -> get id and set parameters + // - SET_NETWORK ssid "ssid" + // - if password: + // SET_NETWORK psk "password" + // else: + // SET_NETWORK key_mgmt NONE + const new_wifi_id = try wpa_ctrl.addNetwork(); + errdefer wpa_ctrl.removeNetwork(new_wifi_id) catch |err| { + logger.err("addWifiNetwork err cleanup: {any}", .{err}); + }; + var buf: [128:0]u8 = undefined; + // TODO: convert ssid to hex string, to support special characters + const ssidZ = try std.fmt.bufPrintZ(&buf, "\"{s}\"", .{ssid}); + try wpa_ctrl.setNetworkParam(new_wifi_id, "ssid", ssidZ); + if (password.len > 0) { + // TODO: switch to wpa_passphrase + const v = try std.fmt.bufPrintZ(&buf, "\"{s}\"", .{password}); + try wpa_ctrl.setNetworkParam(new_wifi_id, "psk", v); + } else { + try wpa_ctrl.setNetworkParam(new_wifi_id, "key_mgmt", "NONE"); + } + + // - LIST_NETWORKS: network id / ssid / bssid / flags + // - for each matching ssid unless it's newly created: REMOVE_NETWORK + if (queryWifiNetworksList(gpa, wpa_ctrl, .{ .ssid = ssid })) |res| { + defer gpa.free(res); + for (res) |id| { + if (id == new_wifi_id) { + continue; + } + wpa_ctrl.removeNetwork(id) catch |err| { + logger.err("wpa_ctrl.removeNetwork({}): {any}", .{ id, err }); + }; + } + } else |err| { + logger.err("queryWifiNetworksList({s}): {any}; won't remove existing, if any", .{ ssid, err }); + } + + return new_wifi_id; +} + +/// reports network status to the writer w in `comm.Message.NetworkReport` format. +pub fn sendReport(gpa: mem.Allocator, wpa_ctrl: *types.WpaControl, w: anytype) !void { + var report = comm.Message.NetworkReport{ + .ipaddrs = undefined, + .wifi_ssid = null, + .wifi_scan_networks = undefined, + }; + + // fetch all public IP addresses using getifaddrs + const pubaddr = try nif.pubAddresses(gpa, null); + defer gpa.free(pubaddr); + //var addrs = std.ArrayList([]).init(t.allocator); + var ipaddrs = try gpa.alloc([]const u8, pubaddr.len); + for (pubaddr) |a, i| { + ipaddrs[i] = try std.fmt.allocPrint(gpa, "{s}", .{a}); + } + defer { + for (ipaddrs) |a| gpa.free(a); + gpa.free(ipaddrs); + } + report.ipaddrs = ipaddrs; + + // get currently connected SSID, if any, from WPA ctrl + const ssid = queryWifiSSID(gpa, wpa_ctrl) catch |err| blk: { + logger.err("queryWifiSsid: {any}", .{err}); + break :blk null; + }; + defer if (ssid) |v| gpa.free(v); + report.wifi_ssid = ssid; + + // fetch available wifi networks from scan results using WPA ctrl + var wifi_networks: ?types.StringList = if (queryWifiScanResults(gpa, wpa_ctrl)) |v| v else |err| blk: { + logger.err("queryWifiScanResults: {any}", .{err}); + break :blk null; + }; + defer if (wifi_networks) |*list| list.deinit(); + if (wifi_networks) |list| { + report.wifi_scan_networks = list.items(); + } + + // report everything back to ngui + return comm.write(gpa, w, comm.Message{ .network_report = report }); +} + +/// returns SSID of the currenly connected wifi, if any. +/// callers must free returned value with the same allocator. +fn queryWifiSSID(gpa: mem.Allocator, wpa_ctrl: *types.WpaControl) !?[]const u8 { + var buf: [512:0]u8 = undefined; + const resp = try wpa_ctrl.request("STATUS", &buf, null); + const ssid = "ssid="; + var it = mem.tokenize(u8, resp, "\n"); + while (it.next()) |line| { + if (mem.startsWith(u8, line, ssid)) { + // TODO: check line.len vs ssid.len + const v = try gpa.dupe(u8, line[ssid.len..]); + return v; + } + } + return null; +} + +/// returns a list of all available wifi networks once a scan is complete. +/// the scan is initiated with wpa_ctrl.scan() and it is ready when CTRL-EVENT-SCAN-RESULTS +/// header is present on wpa_ctrl. +/// +/// the retuned value must be free'd with StringList.deinit. +fn queryWifiScanResults(gpa: mem.Allocator, wpa_ctrl: *types.WpaControl) !types.StringList { + var buf: [8192:0]u8 = undefined; // TODO: what if isn't enough? + // first line is banner: "bssid / frequency / signal level / flags / ssid" + const resp = try wpa_ctrl.request("SCAN_RESULTS", &buf, null); + var it = mem.tokenize(u8, resp, "\n"); + if (it.next() == null) { + return error.MissingWifiScanHeader; + } + + var seen = std.BufSet.init(gpa); + defer seen.deinit(); + var list = types.StringList.init(gpa); + errdefer list.deinit(); + while (it.next()) |line| { + // TODO: wpactrl's text protocol won't work for names with control characters + if (mem.lastIndexOfScalar(u8, line, '\t')) |i| { + const s = mem.trim(u8, line[i..], "\t\n"); + if (s.len == 0 or seen.contains(s)) { + continue; + } + try seen.insert(s); + try list.append(s); + } + } + return list; +} + +const WifiNetworksListFilter = struct { + ssid: ?[]const u8, // ignore networks whose ssid doesn't match +}; + +/// returns a list of all configured network IDs or only those matching the filter. +/// the returned value must be free'd with the same allocator. +fn queryWifiNetworksList(gpa: mem.Allocator, wpa_ctrl: *types.WpaControl, filter: WifiNetworksListFilter) ![]u32 { + var buf: [8192:0]u8 = undefined; // TODO: is this enough? + // first line is banner: "network id / ssid / bssid / flags" + const resp = try wpa_ctrl.request("LIST_NETWORKS", &buf, null); + var it = mem.tokenize(u8, resp, "\n"); + if (it.next() == null) { + return error.MissingWifiNetworksListHeader; + } + + var list = std.ArrayList(u32).init(gpa); + while (it.next()) |line| { + var cols = mem.tokenize(u8, line, "\t"); + const id_str = cols.next() orelse continue; // bad line format? + const ssid = cols.next() orelse continue; // bad line format? + const id = std.fmt.parseUnsigned(u32, id_str, 10) catch continue; // skip bad line + if (filter.ssid != null and !mem.eql(u8, filter.ssid.?, ssid)) { + continue; + } + list.append(id) catch {}; // grab anything we can + } + return list.toOwnedSlice(); +} diff --git a/src/ngui.zig b/src/ngui.zig index c11f052..747306d 100644 --- a/src/ngui.zig +++ b/src/ngui.zig @@ -16,10 +16,12 @@ const symbol = @import("ui/symbol.zig"); /// the program can handle it. pub const keep_sigpipe = true; +const logger = std.log.scoped(.ngui); + +// these are auto-closed as soon as main fn terminates. const stdin = std.io.getStdIn().reader(); const stdout = std.io.getStdOut().writer(); const stderr = std.io.getStdErr().writer(); -const logger = std.log.scoped(.ngui); extern "c" fn ui_update_network_status(text: [*:0]const u8, wifi_list: ?[*:0]const u8) void; @@ -31,16 +33,16 @@ var gpa: std.mem.Allocator = undefined; /// 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. -/// 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; +/// the program runs until sigquit is true. +/// set from sighandler or on unrecoverable comm failure with the daemon. +var sigquit: std.Thread.ResetEvent = .{}; + /// 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 @@ -151,28 +153,28 @@ fn updateNetworkStatus(report: comm.Message.NetworkReport) !void { /// loops indefinitely until program exit or comm returns EOS. fn commThreadLoop() void { while (true) { - commThreadLoopCycle() catch |err| logger.err("commThreadLoopCycle: {any}", .{err}); - + commThreadLoopCycle() catch |err| { + logger.err("commThreadLoopCycle: {any}", .{err}); + if (err == error.EndOfStream) { + // pointless to continue running if comms is broken. + // a parent/supervisor is expected to restart ngui. + break; + } + }; std.atomic.spinLoopHint(); - time.sleep(1 * time.ns_per_ms); + time.sleep(10 * time.ns_per_ms); } + logger.info("exiting commThreadLoop", .{}); + sigquit.set(); } +/// runs one cycle of the commThreadLoop: read messages from stdin and update +/// the UI accordingly. fn commThreadLoopCycle() !void { - const msg = comm.read(gpa, stdin) catch |err| { - 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(); - want_quit = true; - ui_mutex.unlock(); - } - return err; - }; + const msg = try comm.read(gpa, stdin); defer comm.free(gpa, msg); - logger.debug("got msg tagged {s}", .{@tagName(msg)}); + logger.debug("got msg: {s}", .{@tagName(msg)}); switch (msg) { .ping => try comm.write(gpa, stdout, comm.Message.pong), .network_report => |report| { @@ -187,14 +189,46 @@ fn commThreadLoopCycle() !void { } } -/// prints messages in the same way std.fmt.format does and exits the process -/// with a non-zero code. -fn fatal(comptime fmt: []const u8, args: anytype) noreturn { - stderr.print(fmt, args) catch {}; - if (fmt[fmt.len - 1] != '\n') { - stderr.writeByte('\n') catch {}; +/// UI thread: LVGL loop runs here. +/// must never block unless in idle/sleep mode. +fn uiThreadLoop() void { + while (true) { + ui_mutex.lock(); + var till_next_ms = lvgl.loopCycle(); // UI loop + const do_state = state; + ui_mutex.unlock(); + + switch (do_state) { + .active => {}, + .alert => {}, + .standby => { + // go into a screen sleep mode due to no user activity + wakeup.reset(); + comm.write(gpa, stdout, comm.Message.standby) catch |err| { + logger.err("comm.write standby: {any}", .{err}); + }; + screen.sleep(&wakeup); // blocking + + // wake up due to touch screen activity or wakeup event is set + logger.info("waking up from sleep", .{}); + ui_mutex.lock(); + if (state == .standby) { + state = .active; + comm.write(gpa, stdout, comm.Message.wakeup) catch |err| { + logger.err("comm.write wakeup: {any}", .{err}); + }; + lvgl.resetIdle(); + } + ui_mutex.unlock(); + continue; + }, + } + + std.atomic.spinLoopHint(); + time.sleep(@max(1, till_next_ms) * time.ns_per_ms); // sleep at least 1ms } - std.process.exit(1); + + logger.info("exiting UI thread loop", .{}); } fn parseArgs(alloc: std.mem.Allocator) !void { @@ -210,7 +244,8 @@ fn parseArgs(alloc: std.mem.Allocator) !void { try stderr.print("{any}\n", .{buildopts.semver}); std.process.exit(0); } else { - fatal("unknown arg name {s}", .{a}); + logger.err("unknown arg name {s}", .{a}); + return error.UnknownArgName; } } } @@ -227,15 +262,10 @@ fn usage(prog: []const u8) !void { } /// 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, + os.SIG.INT, os.SIG.TERM => sigquit.set(), else => {}, } } @@ -258,11 +288,25 @@ pub fn main() anyerror!void { // initalizes display, input driver and finally creates the user interface. ui.init() catch |err| { logger.err("ui.init: {any}", .{err}); - std.process.exit(1); + return err; }; - // start comms with daemon in a seaparate thread. - _ = try std.Thread.spawn(.{}, commThreadLoop, .{}); + // run idle timer indefinitely. + // continue on failure: screen standby won't work at the worst. + _ = lvgl.createTimer(nm_check_idle_time, 2000, null) catch |err| { + logger.err("lvgl.CreateTimer(idle check): {any}", .{err}); + }; + + { + // start the main UI thread. + const th = try std.Thread.spawn(.{}, uiThreadLoop, .{}); + th.detach(); + } + { + // start comms with daemon in a seaparate thread. + const th = try std.Thread.spawn(.{}, commThreadLoop, .{}); + th.detach(); + } // set up a sigterm handler for clean exit. const sa = os.Sigaction{ @@ -272,48 +316,9 @@ pub fn main() anyerror!void { }; try os.sigaction(os.SIG.INT, &sa, null); try os.sigaction(os.SIG.TERM, &sa, null); + sigquit.wait(); - // run idle timer indefinitely - _ = lvgl.createTimer(nm_check_idle_time, 2000, null) catch |err| { - logger.err("idle timer: lvgl.CreateTimer failed: {any}", .{err}); - }; - - // main UI thread; must never block unless in idle/sleep mode - while (!want_quit) { - ui_mutex.lock(); - var till_next_ms = lvgl.loopCycle(); // UI loop - const do_state = state; - ui_mutex.unlock(); - - if (do_state == .standby) { - // go into a screen sleep mode due to no user activity - wakeup.reset(); - comm.write(gpa, stdout, comm.Message.standby) catch |err| { - 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(); - if (state == .standby) { - state = .active; - comm.write(gpa, stdout, comm.Message.wakeup) catch |err| { - logger.err("comm.write wakeup: {any}", .{err}); - }; - lvgl.resetIdle(); - } - ui_mutex.unlock(); - continue; - } - - std.atomic.spinLoopHint(); - 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. + logger.info("main terminated", .{}); } test "tick" { diff --git a/src/test.zig b/src/test.zig index d9e3823..0e2e995 100644 --- a/src/test.zig +++ b/src/test.zig @@ -143,6 +143,28 @@ pub const TestWpaControl = struct { pub fn request(_: Self, _: [:0]const u8, _: [:0]u8, _: ?nif.wpa.ReqCallback) ![]const u8 { return &.{}; } + + pub fn addNetwork(_: *Self) !u32 { + return 12345; + } + + pub fn removeNetwork(_: *Self, id: u32) !void { + _ = id; + } + + pub fn setNetworkParam(_: *Self, id: u32, name: []const u8, val: []const u8) !void { + _ = id; + _ = name; + _ = val; + } + + pub fn selectNetwork(_: *Self, id: u32) !void { + _ = id; + } + + pub fn enableNetwork(_: *Self, id: u32) !void { + _ = id; + } }; /// similar to std.testing.expectEqual but compares slices with expectEqualSlices diff --git a/src/test/guiplay.zig b/src/test/guiplay.zig index 6082574..988e118 100644 --- a/src/test/guiplay.zig +++ b/src/test/guiplay.zig @@ -8,12 +8,12 @@ const logger = std.log.scoped(.play); const stderr = std.io.getStdErr().writer(); var ngui_proc: std.ChildProcess = undefined; -var sigquit = false; +var sigquit: std.Thread.ResetEvent = .{}; 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, + os.SIG.INT, os.SIG.TERM => sigquit.set(), else => {}, } } @@ -72,65 +72,34 @@ fn parseArgs(gpa: std.mem.Allocator) !Flags { 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}); - }; +fn commThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { + comm.write(gpa, w, .ping) catch |err| logger.err("comm.write ping: {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) { + while (true) { 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| { + const msg = comm.read(gpa, r) catch |err| { + if (err == error.EndOfStream) { + sigquit.set(); + break; + } 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}); + comm.write(gpa, w, .{ .poweroff_progress = s1 }) catch |err| logger.err("comm.write: {any}", .{err}); time.sleep(2 * time.ns_per_s); logger.info("sending poweroff status2", .{}); @@ -138,7 +107,7 @@ pub fn main() !void { .{ .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}); + comm.write(gpa, w, .{ .poweroff_progress = s2 }) catch |err| logger.err("comm.write: {any}", .{err}); time.sleep(3 * time.ns_per_s); logger.info("sending poweroff status3", .{}); @@ -146,12 +115,47 @@ pub fn main() !void { .{ .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}); + comm.write(gpa, w, .{ .poweroff_progress = s3 }) catch |err| logger.err("comm.write: {any}", .{err}); }, else => {}, } } + logger.info("exiting comm thread loop", .{}); + sigquit.set(); +} + +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}); + }; + + // ngui proc stdio is auto-closed as soon as its main process terminates. + const uireader = ngui_proc.stdout.?.reader(); + const uiwriter = ngui_proc.stdin.?.writer(); + _ = try std.Thread.spawn(.{}, commThread, .{ gpa, uireader, uiwriter }); + + 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); + sigquit.wait(); + logger.info("killing ngui", .{}); const term = ngui_proc.kill(); logger.info("ngui_proc.kill term: {any}", .{term}); diff --git a/src/types.zig b/src/types.zig index 2d1fef3..c1d0265 100644 --- a/src/types.zig +++ b/src/types.zig @@ -47,3 +47,35 @@ pub const IoPipe = struct { return self.w.writer(); } }; + +// TODO: turns this into a UniqStringList backed by StringArrayHashMap; also see std.BufSet +pub const StringList = struct { + l: std.ArrayList([]const u8), + allocator: std.mem.Allocator, + + const Self = @This(); + + pub fn init(allocator: std.mem.Allocator) Self { + return Self{ + .l = std.ArrayList([]const u8).init(allocator), + .allocator = allocator, + }; + } + + pub fn deinit(self: *Self) void { + for (self.l.items) |a| { + self.allocator.free(a); + } + self.l.deinit(); + } + + pub fn append(self: *Self, s: []const u8) !void { + const item = try self.allocator.dupe(u8, s); + errdefer self.allocator.free(item); + try self.l.append(item); + } + + pub fn items(self: Self) []const []const u8 { + return self.l.items; + } +}; -- 2.41.0