nd,ngui: make program termination reliable on SIGTERM
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details

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.
pull/23/head
alex 1 year ago
parent 532581a246
commit 63eb27bf18
Signed by: x1ddos
GPG Key ID: FDEFB4A63CBD8460

@ -1,12 +1,14 @@
///! daemon/gui communication. //! daemon/gui communication.
///! the protocol is a simple TLV construct: MessageTag(u16), length(u64), json-marshalled Message; //! the protocol is a simple TLV construct: MessageTag(u16), length(u64), json-marshalled Message;
///! little endian. //! little endian.
const std = @import("std"); const std = @import("std");
const json = std.json; const json = std.json;
const mem = std.mem; const mem = std.mem;
const ByteArrayList = @import("types.zig").ByteArrayList; const ByteArrayList = @import("types.zig").ByteArrayList;
const logger = std.log.scoped(.comm);
/// common errors returned by read/write functions. /// common errors returned by read/write functions.
pub const Error = error{ pub const Error = error{
CommReadInvalidTag, CommReadInvalidTag,
@ -71,12 +73,13 @@ pub const MessageTag = enum(u16) {
}; };
/// reads and parses a single message from the input stream reader. /// 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 { pub fn read(allocator: mem.Allocator, reader: anytype) !Message {
// alternative is @intToEnum(reader.ReadIntLittle(u16)) but it may panic. // alternative is @intToEnum(reader.ReadIntLittle(u16)) but it may panic.
const tag = reader.readEnum(MessageTag, .Little) catch { const tag = try reader.readEnum(MessageTag, .Little);
return Error.CommReadInvalidTag;
};
const len = try reader.readIntLittle(u64); const len = try reader.readIntLittle(u64);
if (len == 0) { if (len == 0) {
return switch (tag) { return switch (tag) {

@ -28,21 +28,11 @@ fn usage(prog: []const u8) !void {
, .{prog}); , .{prog});
} }
/// prints messages in the same way std.fmt.format does and exits the process /// nd program flags. see usage.
/// 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.
const NdArgs = struct { const NdArgs = struct {
gui: ?[:0]const u8 = null, // = "ngui", gui: ?[:0]const u8 = null,
gui_user: ?[:0]const u8 = null, // u8 = "uiuser", gui_user: ?[:0]const u8 = null,
wpa: ?[:0]const u8 = null, // = "/var/run/wpa_supplicant/wlan0", wpa: ?[:0]const u8 = null,
fn deinit(self: @This(), allocator: std.mem.Allocator) void { fn deinit(self: @This(), allocator: std.mem.Allocator) void {
if (self.gui) |p| allocator.free(p); 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")) { } else if (std.mem.eql(u8, a, "-wpa")) {
lastarg = .wpa; lastarg = .wpa;
} else { } else {
fatal("unknown arg name {s}", .{a}); logger.err("unknown arg name {s}", .{a});
return error.UnknownArgName;
} }
} }
if (lastarg != .none) { 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; return flags;
} }
/// sigquit signals nd main loop to exit. /// sigquit tells nd to exit.
/// since both the loop and sighandler are on the same thread, it must var sigquit: std.Thread.ResetEvent = .{};
/// not be guarded by a mutex which otherwise leads to a dealock.
var sigquit = false;
fn sighandler(sig: c_int) callconv(.C) void { fn sighandler(sig: c_int) callconv(.C) void {
logger.info("received signal {}", .{sig}); logger.info("received signal {}", .{sig});
switch (sig) { switch (sig) {
os.SIG.INT, os.SIG.TERM => sigquit = true, os.SIG.INT, os.SIG.TERM => sigquit.set(),
else => {}, else => {},
} }
} }
@ -141,7 +140,8 @@ pub fn main() !void {
screen.backlight(.on) catch |err| logger.err("backlight: {any}", .{err}); screen.backlight(.on) catch |err| logger.err("backlight: {any}", .{err});
// start ngui, unless -nogui mode // 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.stdin_behavior = .Pipe;
ngui.stdout_behavior = .Pipe; ngui.stdout_behavior = .Pipe;
ngui.stderr_behavior = .Inherit; ngui.stderr_behavior = .Inherit;
@ -158,17 +158,29 @@ pub fn main() !void {
//ngui.uid = uiuser.uid; //ngui.uid = uiuser.uid;
//ngui.gid = uiuser.gid; //ngui.gid = uiuser.gid;
// ngui.env_map = ... // 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 uireader = ngui.stdout.?.reader();
const uiwriter = ngui.stdin.?.writer(); const uiwriter = ngui.stdin.?.writer();
// send UI a ping as the first thing to make sure pipes are working. // send UI a ping right away to make sure pipes are working, crash otherwise.
// https://git.qcode.ch/nakamochi/ndg/issues/16
comm.write(gpa, uiwriter, .ping) catch |err| { comm.write(gpa, uiwriter, .ping) catch |err| {
logger.err("comm.write ping: {any}", .{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) // graceful shutdown; see sigaction(2)
const sa = os.Sigaction{ const sa = os.Sigaction{
.handler = .{ .handler = sighandler }, .handler = .{ .handler = sighandler },
@ -177,71 +189,13 @@ pub fn main() !void {
}; };
try os.sigaction(os.SIG.INT, &sa, null); try os.sigaction(os.SIG.INT, &sa, null);
try os.sigaction(os.SIG.TERM, &sa, null); try os.sigaction(os.SIG.TERM, &sa, null);
sigquit.wait();
var nd = try Daemon.init(gpa, uiwriter, args.wpa.?); // reached here due to sig TERM or INT.
defer nd.deinit(); // tell deamon to terminate threads.
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});
nd.stop(); 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();
} }

@ -1,30 +1,30 @@
//! daemon watches network status and communicates updates to the GUI using uiwriter. //! daemon watches network status and communicates updates to the GUI using uiwriter.
//! public fields are allocator
//! usage example: //! usage example:
//! //!
//! var ctrl = try nif.wpa.Control.open("/run/wpa_supplicant/wlan0"); //! var nd = Daemon.init(gpa, ngui_io_reader, ngui_io_writer, "/run/wpa_suppl/wlan0");
//! defer ctrl.close() catch {}; //! defer nd.deinit();
//! var nd: Daemon = .{
//! .allocator = gpa,
//! .uiwriter = ngui_stdio_writer,
//! .wpa_ctrl = ctrl,
//! };
//! try nd.start(); //! try nd.start();
//! // wait for sigterm...
//! nd.stop();
//! // terminate ngui proc...
//! nd.wait();
//!
const builtin = @import("builtin");
const std = @import("std"); const std = @import("std");
const mem = std.mem; const mem = std.mem;
const time = std.time; const time = std.time;
const nif = @import("nif");
const comm = @import("../comm.zig"); const comm = @import("../comm.zig");
const network = @import("network.zig");
const screen = @import("../ui/screen.zig"); const screen = @import("../ui/screen.zig");
const types = @import("../types.zig"); const types = @import("../types.zig");
const SysService = @import("SysService.zig"); const SysService = @import("SysService.zig");
const logger = std.log.scoped(.netmon); const logger = std.log.scoped(.daemon);
allocator: mem.Allocator, allocator: mem.Allocator,
uireader: std.fs.File.Reader, // ngui stdout
uiwriter: std.fs.File.Writer, // ngui stdin uiwriter: std.fs.File.Writer, // ngui stdin
wpa_ctrl: types.WpaControl, // guarded by mu once start'ed wpa_ctrl: types.WpaControl, // guarded by mu once start'ed
@ -35,17 +35,20 @@ mu: std.Thread.Mutex = .{},
state: enum { state: enum {
stopped, stopped,
running, running,
standby,
poweroff, poweroff,
}, },
main_thread: ?std.Thread = null, main_thread: ?std.Thread = null,
comm_thread: ?std.Thread = null,
poweroff_thread: ?std.Thread = null, poweroff_thread: ?std.Thread = null,
want_stop: bool = false, // tells daemon main loop to quit want_stop: bool = false, // tells daemon main loop to quit
want_network_report: bool = false, // network flags
want_wifi_scan: bool = false, 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, 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, wpa_save_config_on_connected: bool = false,
/// system services actively managed by the daemon. /// system services actively managed by the daemon.
@ -55,8 +58,10 @@ services: []SysService = &.{},
const Daemon = @This(); 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. /// 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); var svlist = std.ArrayList(SysService).init(a);
errdefer { errdefer {
for (svlist.items) |*sv| sv.deinit(); 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 })); try svlist.append(SysService.init(a, "bitcoind", .{ .stop_wait_sec = 600 }));
return .{ return .{
.allocator = a, .allocator = a,
.uiwriter = iogui, .uireader = r,
.wpa_ctrl = try types.WpaControl.open(wpa_path), .uiwriter = w,
.wpa_ctrl = try types.WpaControl.open(wpa),
.state = .stopped, .state = .stopped,
.services = svlist.toOwnedSlice(), .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. /// 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 { 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}); self.wpa_ctrl.close() catch |err| logger.err("deinit: wpa_ctrl.close: {any}", .{err});
for (self.services) |*sv| { for (self.services) |*sv| {
sv.deinit(); sv.deinit();
@ -93,95 +95,124 @@ pub fn deinit(self: *Daemon) void {
self.allocator.free(self.services); self.allocator.free(self.services);
} }
/// start launches a main thread and returns immediately. /// start launches daemon threads and returns immediately.
/// once started, the daemon must be eventually stop'ed to clean up resources /// once started, the daemon must be eventually stop'ed and wait'ed to clean up
/// even if a poweroff sequence is launched with beginPoweroff. however, in the latter /// resources even if a poweroff sequence is initiated with beginPoweroff.
/// case the daemon cannot be start'ed again after stop.
pub fn start(self: *Daemon) !void { pub fn start(self: *Daemon) !void {
self.mu.lock(); self.mu.lock();
defer self.mu.unlock(); defer self.mu.unlock();
switch (self.state) { switch (self.state) {
.running => return error.AlreadyStarted,
.poweroff => return error.InPoweroffState,
.stopped => {}, // continue .stopped => {}, // continue
.poweroff => return error.InPoweroffState,
else => return error.AlreadyStarted,
} }
try self.wpa_ctrl.attach(); 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.main_thread = try std.Thread.spawn(.{}, mainThreadLoop, .{self});
self.comm_thread = try std.Thread.spawn(.{}, commThreadLoop, .{self});
self.state = .running; self.state = .running;
} }
/// stop blocks until all daemon threads exit, including poweroff if any. /// tells the daemon to stop threads to prepare for termination.
/// once stopped, the daemon can be start'ed again unless a poweroff was initiated. /// stop returns immediately.
/// /// callers must `wait` to release all resources.
/// note: stop leaves system services like lnd and bitcoind running.
pub fn stop(self: *Daemon) void { pub fn stop(self: *Daemon) void {
self.mu.lock(); self.mu.lock();
if (self.want_stop or self.state == .stopped) { defer self.mu.unlock();
self.mu.unlock();
return; // already in progress or stopped
}
self.want_stop = true; 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| { if (self.main_thread) |th| {
th.join(); th.join();
self.main_thread = null; 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. // must be the last one to join because it sends a final poweroff report.
if (self.poweroff_thread) |th| { if (self.poweroff_thread) |th| {
th.join(); th.join();
self.poweroff_thread = null; self.poweroff_thread = null;
} }
self.mu.lock(); self.wpa_ctrl.detach() catch |err| logger.err("wait: wpa_ctrl.detach: {any}", .{err});
defer self.mu.unlock();
self.want_stop = false; self.want_stop = false;
if (self.state != .poweroff) { // keep poweroff to prevent start'ing again self.state = .stopped;
self.state = .stopped;
}
self.wpa_ctrl.detach() catch |err| logger.err("stop: wpa_ctrl.detach: {any}", .{err});
} }
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(); self.mu.lock();
defer self.mu.unlock(); defer self.mu.unlock();
switch (self.state) { switch (self.state) {
.poweroff => return error.InPoweroffState, .standby => {},
.running, .stopped => {}, // continue .stopped, .poweroff => return error.InvalidState,
.running => {
try screen.backlight(.off);
self.state = .standby;
},
} }
try screen.backlight(.off);
} }
pub fn wakeup(_: *Daemon) !void { /// tells the daemon to return from standby, typically due to user interaction.
try screen.backlight(.on); 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 /// initiates system poweroff sequence in a separate thread: shut down select
/// system services such as lnd and bitcoind, and issue "poweroff" command. /// 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. /// beingPoweroff also makes other threads exit but callers must still call `wait`
/// however, in poweroff mode regular functionalities are disabled, such as /// to make sure poweroff sequence is complete.
/// wifi scan and standby. fn beginPoweroff(self: *Daemon) !void {
pub fn beginPoweroff(self: *Daemon) !void {
self.mu.lock(); self.mu.lock();
defer self.mu.unlock(); defer self.mu.unlock();
if (self.state == .poweroff) { switch (self.state) {
return; // already in poweroff 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 /// set when poweroff_thread starts. available in tests only.
// the progress to ngui. var test_poweroff_started = if (builtin.is_test) std.Thread.ResetEvent{} else {};
fn poweroffThread(self: *Daemon) !void {
/// 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", .{}); logger.info("begin powering off", .{});
screen.backlight(.on) catch |err| { screen.backlight(.on) catch |err| {
logger.err("screen.backlight(.on) during poweroff: {any}", .{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. // initiate shutdown of all services concurrently.
for (self.services) |*sv| { for (self.services) |*sv| {
@ -202,48 +233,110 @@ fn poweroffThread(self: *Daemon) !void {
logger.info("poweroff: {any}", .{res}); logger.info("poweroff: {any}", .{res});
} }
/// main thread entry point. /// main thread entry point: watches for want_xxx flags and monitors network.
fn mainThreadLoop(self: *Daemon) !void { /// exits when want_stop is true.
fn mainThreadLoop(self: *Daemon) void {
var quit = false; var quit = false;
while (!quit) { while (!quit) {
self.mainThreadLoopCycle() catch |err| logger.err("main thread loop: {any}", .{err}); self.mainThreadLoopCycle() catch |err| logger.err("main thread loop: {any}", .{err});
std.atomic.spinLoopHint();
time.sleep(1 * time.ns_per_s); time.sleep(1 * time.ns_per_s);
self.mu.lock(); self.mu.lock();
quit = self.want_stop; quit = self.want_stop;
self.mu.unlock(); self.mu.unlock();
} }
logger.info("exiting main thread loop", .{});
} }
/// run one cycle of the main thread loop iteration. /// runs one cycle of the main thread loop iteration.
/// unless in poweroff mode, the cycle holds self.mu for the whole duration. /// the cycle holds self.mu for the whole duration.
fn mainThreadLoopCycle(self: *Daemon) !void { fn mainThreadLoopCycle(self: *Daemon) !void {
switch (self.state) { self.mu.lock();
// poweroff mode: do nothing; handled by poweroffThread defer self.mu.unlock();
.poweroff => {}, self.readWPACtrlMsg() catch |err| logger.err("readWPACtrlMsg: {any}", .{err});
// normal state: running or standby if (self.want_wifi_scan) {
else => { 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(); self.mu.lock();
defer self.mu.unlock(); defer self.mu.unlock();
self.readWPACtrlMsg() catch |err| logger.err("readWPACtrlMsg: {any}", .{err}); if (self.want_stop) {
if (self.want_wifi_scan) { break :loop; // pipe is most likely already closed
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) { switch (self.state) {
if (self.sendNetworkReport()) { .stopped, .poweroff => break :loop,
self.want_network_report = false; .running, .standby => {
} else |err| { logger.err("commThreadLoop: {any}", .{err});
logger.err("sendNetworkReport: {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 { fn sendPoweroffReport(self: *Daemon) !void {
var svstat = try self.allocator.alloc(comm.Message.PoweroffProgress.Service, self.services.len); var svstat = try self.allocator.alloc(comm.Message.PoweroffProgress.Service, self.services.len);
defer self.allocator.free(svstat); 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. /// 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 { fn wifiInvalidKey(self: *Daemon) void {
self.wpa_save_config_on_connected = false; self.wpa_save_config_on_connected = false;
self.want_network_report = true; self.want_network_report = true;
self.network_report_ready = true; self.network_report_ready = true;
} }
pub const ReportNetworkStatusOpt = struct { const ReportNetworkStatusOpt = struct {
scan: bool, 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(); self.mu.lock();
defer self.mu.unlock(); defer self.mu.unlock();
self.want_network_report = true; 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) { if (ssid.len == 0) {
return error.ConnectWifiEmptySSID; return error.ConnectWifiEmptySSID;
} }
@ -320,21 +416,24 @@ pub fn startConnectWifi(self: *Daemon, ssid: []const u8, password: []const u8) !
th.detach(); 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 { fn connectWifiThread(self: *Daemon, ssid: []const u8, password: []const u8) void {
self.mu.lock();
defer { defer {
self.mu.unlock();
self.allocator.free(ssid); self.allocator.free(ssid);
self.allocator.free(password); self.allocator.free(password);
} }
// https://hostap.epitest.fi/wpa_supplicant/devel/ctrl_iface_page.html // https://hostap.epitest.fi/wpa_supplicant/devel/ctrl_iface_page.html
// https://wiki.archlinux.org/title/WPA_supplicant // https://wiki.archlinux.org/title/WPA_supplicant
// this prevents main thread from looping until released, const id = network.addWifi(self.allocator, &self.wpa_ctrl, ssid, password) catch |err| {
// but the following commands and expected to be pretty quick. logger.err("addWifi: {any}; exiting", .{err});
self.mu.lock();
defer self.mu.unlock();
const id = self.addWifiNetwork(ssid, password) catch |err| {
logger.err("addWifiNetwork: {any}; exiting", .{err});
return; return;
}; };
// SELECT_NETWORK <id> - this disables others // SELECT_NETWORK <id> - 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; self.wpa_save_config_on_connected = true;
} }
/// adds a new network and configures its parameters. /// reads all available messages from self.wpa_ctrl and acts accordingly.
/// caller must hold self.mu. /// callers must hold self.mu.
fn addWifiNetwork(self: *Daemon, ssid: []const u8, password: []const u8) !u32 {
// - ADD_NETWORK -> get id and set parameters
// - SET_NETWORK <id> ssid "ssid"
// - if password:
// SET_NETWORK <id> psk "password"
// else:
// SET_NETWORK <id> 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 <id>
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.
fn readWPACtrlMsg(self: *Daemon) !void { fn readWPACtrlMsg(self: *Daemon) !void {
var buf: [512:0]u8 = undefined; var buf: [512:0]u8 = undefined;
while (try self.wpa_ctrl.pending()) { 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" { test "start-stop" {
const t = std.testing; const t = std.testing;
const pipe = try types.IoPipe.create(); const pipe = try types.IoPipe.create();
defer pipe.close(); var daemon = try Daemon.init(t.allocator, pipe.reader(), pipe.writer(), "/dev/null");
var daemon = try Daemon.init(t.allocator, pipe.writer(), "/dev/null"); daemon.want_network_report = false;
try t.expect(daemon.state == .stopped); try t.expect(daemon.state == .stopped);
try daemon.start(); try daemon.start();
try t.expect(daemon.state == .running); 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.opened);
try t.expect(daemon.wpa_ctrl.attached); try t.expect(daemon.wpa_ctrl.attached);
daemon.stop(); daemon.stop();
pipe.close();
daemon.wait();
try t.expect(daemon.state == .stopped); 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.main_thread == null);
try t.expect(daemon.comm_thread == null);
try t.expect(daemon.poweroff_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); try t.expect(daemon.services.len > 0);
for (daemon.services) |*sv| { for (daemon.services) |*sv| {
@ -610,7 +520,7 @@ test "start-stop" {
try t.expect(!daemon.wpa_ctrl.opened); try t.expect(!daemon.wpa_ctrl.opened);
} }
test "start-poweroff-stop" { test "start-poweroff" {
const t = std.testing; const t = std.testing;
const tt = @import("../test.zig"); const tt = @import("../test.zig");
@ -618,37 +528,44 @@ test "start-poweroff-stop" {
defer arena_alloc.deinit(); defer arena_alloc.deinit();
const arena = arena_alloc.allocator(); const arena = arena_alloc.allocator();
const pipe = try types.IoPipe.create(); const gui_stdin = try types.IoPipe.create();
var daemon = try Daemon.init(arena, pipe.writer(), "/dev/null"); 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 { defer {
daemon.deinit(); daemon.deinit();
pipe.close(); gui_stdin.close();
} }
try daemon.start(); try daemon.start();
try daemon.beginPoweroff(); try comm.write(arena, gui_stdout.writer(), comm.Message.poweroff);
daemon.stop(); try test_poweroff_started.timedWait(2 * time.ns_per_s);
try t.expect(daemon.state == .poweroff); 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| { for (daemon.services) |*sv| {
try t.expect(sv.stop_proc.spawned); try t.expect(sv.stop_proc.spawned);
try t.expect(sv.stop_proc.waited); try t.expect(sv.stop_proc.waited);
try t.expectEqual(SysService.Status.stopped, sv.status()); try t.expectEqual(SysService.Status.stopped, sv.status());
} }
const pipe_reader = pipe.reader(); const msg1 = try comm.read(arena, gui_reader);
const msg1 = try comm.read(arena, pipe_reader);
try tt.expectDeepEqual(comm.Message{ .poweroff_progress = .{ .services = &.{ try tt.expectDeepEqual(comm.Message{ .poweroff_progress = .{ .services = &.{
.{ .name = "lnd", .stopped = false, .err = null }, .{ .name = "lnd", .stopped = false, .err = null },
.{ .name = "bitcoind", .stopped = false, .err = null }, .{ .name = "bitcoind", .stopped = false, .err = null },
} } }, msg1); } } }, 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 = &.{ try tt.expectDeepEqual(comm.Message{ .poweroff_progress = .{ .services = &.{
.{ .name = "lnd", .stopped = true, .err = null }, .{ .name = "lnd", .stopped = true, .err = null },
.{ .name = "bitcoind", .stopped = false, .err = null }, .{ .name = "bitcoind", .stopped = false, .err = null },
} } }, msg2); } } }, 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 = &.{ try tt.expectDeepEqual(comm.Message{ .poweroff_progress = .{ .services = &.{
.{ .name = "lnd", .stopped = true, .err = null }, .{ .name = "lnd", .stopped = true, .err = null },
.{ .name = "bitcoind", .stopped = true, .err = null }, .{ .name = "bitcoind", .stopped = true, .err = null },

@ -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 <id> ssid "ssid"
// - if password:
// SET_NETWORK <id> psk "password"
// else:
// SET_NETWORK <id> 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 <id>
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();
}

@ -16,10 +16,12 @@ const symbol = @import("ui/symbol.zig");
/// the program can handle it. /// the program can handle it.
pub const keep_sigpipe = true; 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 stdin = std.io.getStdIn().reader();
const stdout = std.io.getStdOut().writer(); const stdout = std.io.getStdOut().writer();
const stderr = std.io.getStdErr().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; 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. /// all nm_xxx functions assume it is the case since they are invoked from lvgl c code.
var ui_mutex: std.Thread.Mutex = .{}; 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 { var state: enum {
active, // normal operational mode active, // normal operational mode
standby, // idling standby, // idling
alert, // draw user attention; never go standby alert, // draw user attention; never go standby
} = .active; } = .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. /// 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. /// 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 /// 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. /// loops indefinitely until program exit or comm returns EOS.
fn commThreadLoop() void { fn commThreadLoop() void {
while (true) { 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(); std.atomic.spinLoopHint();
time.sleep(1 * time.ns_per_ms); time.sleep(10 * time.ns_per_ms);
} }
logger.info("exiting commThreadLoop", .{}); logger.info("exiting commThreadLoop", .{});
sigquit.set();
} }
/// runs one cycle of the commThreadLoop: read messages from stdin and update
/// the UI accordingly.
fn commThreadLoopCycle() !void { fn commThreadLoopCycle() !void {
const msg = comm.read(gpa, stdin) catch |err| { const msg = try comm.read(gpa, stdin);
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;
};
defer comm.free(gpa, msg); defer comm.free(gpa, msg);
logger.debug("got msg tagged {s}", .{@tagName(msg)}); logger.debug("got msg: {s}", .{@tagName(msg)});
switch (msg) { switch (msg) {
.ping => try comm.write(gpa, stdout, comm.Message.pong), .ping => try comm.write(gpa, stdout, comm.Message.pong),
.network_report => |report| { .network_report => |report| {
@ -187,14 +189,46 @@ fn commThreadLoopCycle() !void {
} }
} }
/// prints messages in the same way std.fmt.format does and exits the process /// UI thread: LVGL loop runs here.
/// with a non-zero code. /// must never block unless in idle/sleep mode.
fn fatal(comptime fmt: []const u8, args: anytype) noreturn { fn uiThreadLoop() void {
stderr.print(fmt, args) catch {}; while (true) {
if (fmt[fmt.len - 1] != '\n') { ui_mutex.lock();
stderr.writeByte('\n') catch {}; 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 { 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}); try stderr.print("{any}\n", .{buildopts.semver});
std.process.exit(0); std.process.exit(0);
} else { } 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. /// 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 { fn sighandler(sig: c_int) callconv(.C) void {
logger.info("received signal {}", .{sig}); logger.info("received signal {}", .{sig});
switch (sig) { switch (sig) {
os.SIG.INT, os.SIG.TERM => want_quit = true, os.SIG.INT, os.SIG.TERM => sigquit.set(),
else => {}, else => {},
} }
} }
@ -258,11 +288,25 @@ pub fn main() anyerror!void {
// initalizes display, input driver and finally creates the user interface. // initalizes display, input driver and finally creates the user interface.
ui.init() catch |err| { ui.init() catch |err| {
logger.err("ui.init: {any}", .{err}); logger.err("ui.init: {any}", .{err});
std.process.exit(1); return err;
}; };
// start comms with daemon in a seaparate thread. // run idle timer indefinitely.
_ = try std.Thread.spawn(.{}, commThreadLoop, .{}); // 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. // set up a sigterm handler for clean exit.
const sa = os.Sigaction{ 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.INT, &sa, null);
try os.sigaction(os.SIG.TERM, &sa, null); try os.sigaction(os.SIG.TERM, &sa, null);
sigquit.wait();
// run idle timer indefinitely logger.info("main terminated", .{});
_ = 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.
} }
test "tick" { test "tick" {

@ -143,6 +143,28 @@ pub const TestWpaControl = struct {
pub fn request(_: Self, _: [:0]const u8, _: [:0]u8, _: ?nif.wpa.ReqCallback) ![]const u8 { pub fn request(_: Self, _: [:0]const u8, _: [:0]u8, _: ?nif.wpa.ReqCallback) ![]const u8 {
return &.{}; 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 /// similar to std.testing.expectEqual but compares slices with expectEqualSlices

@ -8,12 +8,12 @@ const logger = std.log.scoped(.play);
const stderr = std.io.getStdErr().writer(); const stderr = std.io.getStdErr().writer();
var ngui_proc: std.ChildProcess = undefined; var ngui_proc: std.ChildProcess = undefined;
var sigquit = false; var sigquit: std.Thread.ResetEvent = .{};
fn sighandler(sig: c_int) callconv(.C) void { fn sighandler(sig: c_int) callconv(.C) void {
logger.info("received signal {} (TERM={} INT={})", .{ sig, os.SIG.TERM, os.SIG.INT }); logger.info("received signal {} (TERM={} INT={})", .{ sig, os.SIG.TERM, os.SIG.INT });
switch (sig) { switch (sig) {
os.SIG.INT, os.SIG.TERM => sigquit = true, os.SIG.INT, os.SIG.TERM => sigquit.set(),
else => {}, else => {},
} }
} }
@ -72,65 +72,34 @@ fn parseArgs(gpa: std.mem.Allocator) !Flags {
return flags; return flags;
} }
pub fn main() !void { fn commThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void {
var gpa_state = std.heap.GeneralPurposeAllocator(.{}){}; comm.write(gpa, w, .ping) catch |err| logger.err("comm.write ping: {any}", .{err});
defer if (gpa_state.deinit()) {
logger.err("memory leaks detected", .{});
};
const gpa = gpa_state.allocator();
const flags = try parseArgs(gpa);
defer flags.deinit(gpa);
ngui_proc = std.ChildProcess.init(&.{flags.ngui_path.?}, gpa);
ngui_proc.stdin_behavior = .Pipe;
ngui_proc.stdout_behavior = .Pipe;
ngui_proc.stderr_behavior = .Inherit;
ngui_proc.spawn() catch |err| {
fatal("unable to start ngui: {any}", .{err});
};
const sa = os.Sigaction{ while (true) {
.handler = .{ .handler = sighandler },
.mask = os.empty_sigset,
.flags = 0,
};
try os.sigaction(os.SIG.INT, &sa, null);
try os.sigaction(os.SIG.TERM, &sa, null);
const uireader = ngui_proc.stdout.?.reader();
const uiwriter = ngui_proc.stdin.?.writer();
comm.write(gpa, uiwriter, .ping) catch |err| {
logger.err("comm.write ping: {any}", .{err});
};
var poweroff = false;
while (!sigquit) {
std.atomic.spinLoopHint(); std.atomic.spinLoopHint();
time.sleep(100 * time.ns_per_ms); 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}); logger.err("comm.read: {any}", .{err});
continue; continue;
}; };
logger.debug("got ui msg tagged {s}", .{@tagName(msg)}); logger.debug("got ui msg tagged {s}", .{@tagName(msg)});
switch (msg) { switch (msg) {
.pong => { .pong => {
logger.info("received pong from ngui", .{}); logger.info("received pong from ngui", .{});
}, },
.poweroff => { .poweroff => {
poweroff = true;
logger.info("sending poweroff status1", .{}); logger.info("sending poweroff status1", .{});
var s1: comm.Message.PoweroffProgress = .{ .services = &.{ var s1: comm.Message.PoweroffProgress = .{ .services = &.{
.{ .name = "lnd", .stopped = false, .err = null }, .{ .name = "lnd", .stopped = false, .err = null },
.{ .name = "bitcoind", .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); time.sleep(2 * time.ns_per_s);
logger.info("sending poweroff status2", .{}); logger.info("sending poweroff status2", .{});
@ -138,7 +107,7 @@ pub fn main() !void {
.{ .name = "lnd", .stopped = true, .err = null }, .{ .name = "lnd", .stopped = true, .err = null },
.{ .name = "bitcoind", .stopped = false, .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); time.sleep(3 * time.ns_per_s);
logger.info("sending poweroff status3", .{}); logger.info("sending poweroff status3", .{});
@ -146,12 +115,47 @@ pub fn main() !void {
.{ .name = "lnd", .stopped = true, .err = null }, .{ .name = "lnd", .stopped = true, .err = null },
.{ .name = "bitcoind", .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 => {}, 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", .{}); logger.info("killing ngui", .{});
const term = ngui_proc.kill(); const term = ngui_proc.kill();
logger.info("ngui_proc.kill term: {any}", .{term}); logger.info("ngui_proc.kill term: {any}", .{term});

@ -47,3 +47,35 @@ pub const IoPipe = struct {
return self.w.writer(); 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;
}
};