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; + } +};