nd,ngui: make program termination reliable on SIGTERM #23

Manually merged
x1ddos merged 1 commits from sigterm into master 1 year ago

@ -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); try screen.backlight(.off);
self.state = .standby;
},
}
} }
pub fn wakeup(_: *Daemon) !void { /// 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); 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.poweroff_thread = try std.Thread.spawn(.{}, poweroffThread, .{self});
self.state = .poweroff; self.state = .poweroff;
self.want_stop = true;
},
}
} }
// 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,27 +233,25 @@ 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) {
// poweroff mode: do nothing; handled by poweroffThread
.poweroff => {},
// normal state: running or standby
else => {
self.mu.lock(); self.mu.lock();
defer self.mu.unlock(); defer self.mu.unlock();
self.readWPACtrlMsg() catch |err| logger.err("readWPACtrlMsg: {any}", .{err}); self.readWPACtrlMsg() catch |err| logger.err("readWPACtrlMsg: {any}", .{err});
@ -234,16 +263,80 @@ fn mainThreadLoopCycle(self: *Daemon) !void {
} }
} }
if (self.want_network_report and self.network_report_ready) { if (self.want_network_report and self.network_report_ready) {
if (self.sendNetworkReport()) { if (network.sendReport(self.allocator, &self.wpa_ctrl, self.uiwriter)) {
self.want_network_report = false; self.want_network_report = false;
} else |err| { } else |err| {
logger.err("sendNetworkReport: {any}", .{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();
if (self.want_stop) {
break :loop; // pipe is most likely already closed
}
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 { 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();
} }
std.process.exit(1); ui_mutex.unlock();
continue;
},
}
std.atomic.spinLoopHint();
time.sleep(@max(1, till_next_ms) * time.ns_per_ms); // sleep at least 1ms
}
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;
};
// 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. // start comms with daemon in a seaparate thread.
_ = try std.Thread.spawn(.{}, commThreadLoop, .{}); 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;
}
};