From 21ee4ec85532c149b02727a9fad16db846f77df7 Mon Sep 17 00:00:00 2001 From: jrtxio Date: Fri, 2 Oct 2026 07:45:57 +0800 Subject: [PATCH 1/3] linux runtime: build on hosts without SOCK_CLOEXEC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SOCK_CLOEXEC is a Linux-only socketpair flag; macOS (the dev-iteration harness for Linux hosts) lacks it, so platform/linux/runtime did not compile outside Linux. Fall back to setting FD_CLOEXEC on both ends right after the pair is created — this thread performs no I/O in between, so no descriptor can leak across an exec in that window. Verified: the BrainFuel GTK4 host now compiles, links, and runs against this runtime on macOS (arm64, libracketcs) with the same sources that CI compiles on Linux. --- platform/linux/runtime/backend.cpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/platform/linux/runtime/backend.cpp b/platform/linux/runtime/backend.cpp index 4751e7e..48b4440 100644 --- a/platform/linux/runtime/backend.cpp +++ b/platform/linux/runtime/backend.cpp @@ -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))); } From 41cba0d2549c7a4e026ef32470a6c8f8fc6d6071 Mon Sep 17 00:00:00 2001 From: jrtxio Date: Fri, 2 Oct 2026 07:50:06 +0800 Subject: [PATCH 2/3] Menu bar controller: separators, in-place updates, icon, tooltip, click action Evolves RivetMenuBarController along the lines of #119, keeping the existing install(title:menuItems:) tuple API working: - RivetMenuItem enum (action / separator) with separators rendering as NSMenuItem.separator(); grouping survives into tray menus. - update(items:) rebuilds the menu in place (pause/resume style label swaps no longer reinstall the whole controller); setItem(_:label:) swaps one entry's label without touching the rest. - install(icon:items:) puts a template NSImage in the status bar. - setToolTip(_:) and setClickAction(_:) cover tooltip and the click-without-menu pattern (menu stays reachable via click-and-hold). Covered by RivetSystemTests/MenuBarControllerTests.swift (separator layout, update/replace, setItem label swap, action dispatch). --- .../Sources/RivetSystem/SystemServices.swift | 141 ++++++++++++++++-- .../MenuBarControllerTests.swift | 52 +++++++ 2 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift diff --git a/platform/macos/Sources/RivetSystem/SystemServices.swift b/platform/macos/Sources/RivetSystem/SystemServices.swift index 4c32106..b97e612 100644 --- a/platform/macos/Sources/RivetSystem/SystemServices.swift +++ b/platform/macos/Sources/RivetSystem/SystemServices.swift @@ -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 { diff --git a/platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift b/platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift new file mode 100644 index 0000000..088bdcd --- /dev/null +++ b/platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift @@ -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") +} From 2ecd5d44a979ba4e7bbf69751e438267452331bf Mon Sep 17 00:00:00 2001 From: jrtxio Date: Fri, 2 Oct 2026 07:52:36 +0800 Subject: [PATCH 3/3] linux system adapter: InstallShutdownHook for SIGTERM/SIGINT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-party shutdown plumbing for #120: embedded Racket CS installs its own signal handlers, so a staged host absorbs SIGTERM and exit-time state flushing never runs on OS logout, launchd kills, or plain `kill `. InstallShutdownHook(callback) installs a SIGTERM/SIGINT handler that is async-signal-safe (one write into a self-pipe); a watcher thread runs the callback on a normal stack — file writes and other state flushing are allowed there — and exits with status 0. A second signal restores the default disposition so operators can still hard-kill a stuck shutdown. pipe2(O_CLOEXEC) with a non-blocking read end; falls back to pipe()+fcntl where pipe2 is unavailable. macOS/WinRT get the equivalent surfaces in follow-ups; the pattern matches RivetSystem's crash-hook contract. --- platform/linux/system/system_services.cpp | 101 ++++++++++++++++++++++ platform/linux/system/system_services.hpp | 9 ++ 2 files changed, 110 insertions(+) diff --git a/platform/linux/system/system_services.cpp b/platform/linux/system/system_services.cpp index c266de1..fb7fedf 100644 --- a/platform/linux/system/system_services.cpp +++ b/platform/linux/system/system_services.cpp @@ -8,9 +8,12 @@ #include #include +#include #include #include +#include #include +#include #include #include @@ -731,4 +734,102 @@ void InstallCrashHook(CrashCallback callback, } } +// ---------------------------------------------------------- shutdown hook + +namespace { + +int shutdown_pipe_read = -1; +int shutdown_pipe_write = -1; +std::function shutdown_callback; +std::atomic 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 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 diff --git a/platform/linux/system/system_services.hpp b/platform/linux/system/system_services.hpp index 969f506..c09912a 100644 --- a/platform/linux/system/system_services.hpp +++ b/platform/linux/system/system_services.hpp @@ -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 callback); + } // namespace rivet::system