Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion platform/linux/runtime/backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,20 @@ struct SocketEndpoints {

SocketEndpoints create_socket_endpoints() {
int fds[2]{-1, -1};
if (::socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, fds) != 0) {
#if defined(SOCK_CLOEXEC)
int const rc = ::socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, fds);
#else
// Platforms without SOCK_CLOEXEC (macOS): set the flag on both ends right
// after the pair exists; this thread does no I/O in between.
int rc = ::socketpair(AF_UNIX, SOCK_STREAM, 0, fds);
if (rc == 0) {
if (::fcntl(fds[0], F_SETFD, FD_CLOEXEC) != 0 ||
::fcntl(fds[1], F_SETFD, FD_CLOEXEC) != 0) {
rc = -1;
}
}
#endif
if (rc != 0) {
throw std::runtime_error("socketpair failed: " +
std::string(std::strerror(errno)));
}
Expand Down
101 changes: 101 additions & 0 deletions platform/linux/system/system_services.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
#include <sys/un.h>
#include <unistd.h>

#include <atomic>
#include <cerrno>
#include <cstring>
#include <functional>
#include <mutex>
#include <thread>
#include <stdexcept>
#include <unordered_map>

Expand Down Expand Up @@ -731,4 +734,102 @@ void InstallCrashHook(CrashCallback callback,
}
}

// ---------------------------------------------------------- shutdown hook

namespace {

int shutdown_pipe_read = -1;
int shutdown_pipe_write = -1;
std::function<void()> shutdown_callback;
std::atomic<bool> shutdown_in_flight{false};
std::thread shutdown_watcher;

extern "C" void rivet_shutdown_signal_handler(int) noexcept {
// Async-signal-safe: a single write into the self-pipe is all the handler
// does; everything else happens on the watcher thread.
if (shutdown_pipe_write >= 0) {
char const byte = 's';
ssize_t ignored = ::write(shutdown_pipe_write, &byte, 1);
(void)ignored;
}
}

void rivet_shutdown_watch_loop() {
char byte = 0;
while (true) {
ssize_t const read_bytes = ::read(shutdown_pipe_read, &byte, 1);
if (read_bytes < 0 && errno == EINTR) continue;
break; // pipe closed at teardown, or a signal drained through
}
if (shutdown_in_flight.exchange(true)) return;

// A second signal must hard-kill even if the callback hangs: restore the
// default disposition right before the callback runs.
struct sigaction action;
std::memset(&action, 0, sizeof(action));
action.sa_handler = SIG_DFL;
sigemptyset(&action.sa_mask);
::sigaction(SIGTERM, &action, nullptr);
::sigaction(SIGINT, &action, nullptr);

if (shutdown_callback) shutdown_callback();
std::exit(0);
}

} // namespace

void InstallShutdownHook(std::function<void()> callback) {
if (shutdown_pipe_read >= 0)
throw std::runtime_error("shutdown hook is already installed");

int fds[2]{-1, -1};
#if defined(SOCK_CLOEXEC)
if (::pipe2(fds, O_CLOEXEC) != 0) {
#else
if (::pipe(fds) != 0) {
#endif
throw std::runtime_error(std::string("shutdown hook pipe failed: ") +
std::strerror(errno));
}
#ifndef SOCK_CLOEXEC
::fcntl(fds[0], F_SETFD, FD_CLOEXEC);
::fcntl(fds[1], F_SETFD, FD_CLOEXEC);
#endif
// Non-blocking read end so the watcher can never wedge on a partial pipe.
int const flags = ::fcntl(fds[0], F_GETFL, 0);
::fcntl(fds[0], F_SETFL, flags | O_NONBLOCK);

shutdown_pipe_read = fds[0];
shutdown_pipe_write = fds[1];
shutdown_callback = std::move(callback);

try {
shutdown_watcher = std::thread(rivet_shutdown_watch_loop);
} catch (...) {
::close(shutdown_pipe_read);
::close(shutdown_pipe_write);
shutdown_pipe_read = shutdown_pipe_write = -1;
shutdown_callback = nullptr;
throw;
}

struct sigaction action;
std::memset(&action, 0, sizeof(action));
action.sa_handler = rivet_shutdown_signal_handler;
sigemptyset(&action.sa_mask);
for (int signal_number : {SIGTERM, SIGINT}) {
if (::sigaction(signal_number, &action, nullptr) != 0) {
int const saved = errno;
::sigaction(signal_number, &action, nullptr);
// Best effort unwind; the watcher exits when the pipe is closed.
shutdown_callback = nullptr;
::close(shutdown_pipe_read);
::close(shutdown_pipe_write);
shutdown_pipe_read = shutdown_pipe_write = -1;
throw std::runtime_error(std::string("shutdown hook installation failed: ") +
std::strerror(saved));
}
}
}

} // namespace rivet::system
9 changes: 9 additions & 0 deletions platform/linux/system/system_services.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,13 @@ using CrashCallback = void (*)(int signal_number) noexcept;
void InstallCrashHook(CrashCallback callback,
std::string const& restart_arguments = std::string());

// SIGTERM/SIGINT shutdown plumbing (embedded runtimes install their own
// signal handlers and otherwise absorb termination requests, so hosts that
// persist state on exit never see SIGTERM). The first signal drains into a
// self-pipe; a watcher thread runs `callback` on a normal stack — state
// flushing is allowed there — and the process then exits with status 0. A
// second signal restores the default disposition so operators can still
// hard-kill a stuck shutdown. Install once, early, from the main thread.
void InstallShutdownHook(std::function<void()> callback);

} // namespace rivet::system
141 changes: 130 additions & 11 deletions platform/macos/Sources/RivetSystem/SystemServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,36 +140,155 @@ public enum RivetLoginItem {
}
}

/// One entry of a status-bar menu. `.separator` renders an `NSMenuItem.separator()`
/// and never carries an action; `.action` entries dispatch by `identifier`.
public enum RivetMenuItem {
case action(label: String, identifier: String, handler: () -> Void)
case separator
}

@MainActor
public final class RivetMenuBarController: NSObject {
private var item: NSStatusItem?
private var actions: [String: () -> Void] = [:]
private var currentItems: [RivetMenuItem] = []
private var currentTitle: String = ""
private var currentIcon: NSImage?
private var currentToolTip: String?
// When set, a plain click fires this handler instead of opening the menu;
// the menu stays reachable through click-and-hold (NSStatusItem behavior).
private var clickHandler: (() -> Void)?

public func install(title: String, menuItems: [(String, String, () -> Void)]) {
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem.button?.title = title
let menu = NSMenu()
for (label, identifier, action) in menuItems {
actions[identifier] = action
let entry = NSMenuItem(title: label, action: #selector(invoke(_:)), keyEquivalent: "")
entry.representedObject = identifier
entry.target = self
menu.addItem(entry)
install(
title: title,
items: menuItems.map {
.action(label: $0.0, identifier: $0.1, handler: $0.2)
})
}

public func install(title: String, items: [RivetMenuItem]) {
installStatusItem { button in
button.title = title
}
currentTitle = title
currentIcon = nil
rebuild(items: items)
}

/// Template image in place of a text title; rendered as a template so it
/// follows the menu bar's light/dark appearance.
public func install(icon: NSImage, items: [RivetMenuItem]) {
icon.isTemplate = true
installStatusItem { button in
button.image = icon
}
currentTitle = ""
currentIcon = icon
rebuild(items: items)
}

/// Replace every menu entry in place (labels, handlers, separators),
/// keeping the status item, title/icon, and tooltip.
public func update(items: [RivetMenuItem]) {
rebuild(items: items)
}

/// Swap one entry's label without rebuilding the whole menu.
public func setItem(_ identifier: String, label: String) {
guard let menu = item?.menu else { return }
for entry in menu.items
where entry.representedObject as? String == identifier {
entry.title = label
}
currentItems = currentItems.map { current in
guard case let .action(_, currentIdentifier, handler) = current,
currentIdentifier == identifier
else { return current }
return .action(label: label, identifier: currentIdentifier, handler: handler)
}
}

public func setToolTip(_ text: String?) {
currentToolTip = text
item?.button?.toolTip = text
}

/// Fire this handler on a plain click instead of opening the menu. The
/// menu remains available through click-and-hold. Pass nil to restore
/// menu-at-click.
public func setClickAction(_ handler: (() -> Void)?) {
clickHandler = handler
guard let button = item?.button else { return }
if handler != nil {
button.target = self
button.action = #selector(handleClick(_:))
} else {
button.target = nil
button.action = nil
}
statusItem.menu = menu
item = statusItem
}

public func remove() {
if let item { NSStatusBar.system.removeStatusItem(item) }
item = nil
actions.removeAll()
currentItems = []
currentTitle = ""
currentIcon = nil
currentToolTip = nil
clickHandler = nil
}

/// Test hook: the live NSMenu backing the status item.
var menuForTesting: NSMenu? { item?.menu }

// MARK: internals

private func installStatusItem(_ configure: (NSStatusBarButton) -> Void) {
if let existing = item {
NSStatusBar.system.removeStatusItem(existing)
}
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem.button {
configure(button)
button.toolTip = currentToolTip
if clickHandler != nil {
button.action = #selector(handleClick(_:))
button.target = self
}
}
item = statusItem
}

private func rebuild(items: [RivetMenuItem]) {
let menu = NSMenu()
actions.removeAll()
for entry in items {
switch entry {
case let .action(label, identifier, action):
actions[identifier] = action
let item = NSMenuItem(
title: label, action: #selector(invoke(_:)), keyEquivalent: "")
item.representedObject = identifier
item.target = self
menu.addItem(item)
case .separator:
menu.addItem(NSMenuItem.separator())
}
}
item?.menu = menu
currentItems = items
}

@objc private func invoke(_ sender: NSMenuItem) {
guard let identifier = sender.representedObject as? String else { return }
actions[identifier]?()
}

@objc private func handleClick(_ sender: NSStatusBarButton) {
clickHandler?()
}
}

public final class RivetActivationRouter {
Expand Down
52 changes: 52 additions & 0 deletions platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import AppKit
import Testing
@testable import RivetSystem

@MainActor
@Test func menuBarMenuContainsSeparatorsAndActions() {
let controller = RivetMenuBarController()
var fired = false
controller.install(
title: "Test",
items: [
.action(label: "Show", identifier: "show", handler: {}),
.separator,
.action(label: "Quit", identifier: "quit", handler: { fired = true }),
])
defer { controller.remove() }

let menu = controller.menuForTesting!
#expect(menu.numberOfItems == 3)
#expect(menu.item(at: 0)?.title == "Show")
#expect(menu.item(at: 1)?.isSeparatorItem == true)
#expect(menu.item(at: 2)?.title == "Quit")

// Action dispatch flows through the represented-object identifier.
if let quit = menu.item(at: 2) {
_ = quit.target?.perform(Selector(("invoke:")), with: quit)
}
#expect(fired)
}

@MainActor
@Test func menuBarUpdateReplacesEntriesAndSetItemSwapsOneLabel() {
let controller = RivetMenuBarController()
controller.install(title: "Test", items: [
.action(label: "Pause reminders", identifier: "toggle", handler: {}),
])
defer { controller.remove() }

controller.update(items: [
.action(label: "Resume reminders", identifier: "toggle", handler: {}),
.separator,
.action(label: "Quit", identifier: "quit", handler: {}),
])
let menu = controller.menuForTesting!
#expect(menu.numberOfItems == 3)
#expect(menu.item(at: 0)?.title == "Resume reminders")

controller.setItem("toggle", label: "Pause again")
#expect(menu.item(at: 0)?.title == "Pause again")
// Untouched entries keep their labels.
#expect(menu.item(at: 2)?.title == "Quit")
}
Loading