summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBerke Güzel <wenekar1@gmail.com>2026-10-13 16:55:45 +0300
committerBerke Güzel <wenekar1@gmail.com>2026-10-13 16:55:45 +0300
commitefca84e8b5204c4d5ce28113474df0bf0b67dbf3 (patch)
tree8ab6ed74f1e29b3a9dd3f1b2cb031c81891a5902
parent62717de20d2a0c8d3596a9223e56e734971e3ec5 (diff)
Wake and post to loops from any thread, and watch signals
wsap_term runs its own eventfds to wake the loop from its pty worker and accessibility threads, and its own signalfd or self-pipe per platform. yasl_loop_kick() is now documented as safe from any thread and from a signal handler. yasl_loop_post() queues a function to run on the owning thread during a later step. yasl_loop_watch_signals() installs a process-wide SA_RESTART handler per signal that records the signal and kicks the loop, so callbacks run on the loop thread on every platform without touching thread masks. Cocoa dispatch waited only for AppKit events, so kicks, posts, and fd watches could not end an indefinite wait, and yasl_post_event() did not wake yasl_run(). While waiting, Cocoa now mirrors the loop's descriptors in a kqueue whose run loop source posts a wake event. If kqueue rejects a descriptor, the wait is bounded to 10 ms. The Cocoa change is not built or run here; it needs a macOS check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/guide.md27
-rw-r--r--include/yasl/app.h3
-rw-r--r--include/yasl/loop.h63
-rw-r--r--src/common/loop.h5
-rw-r--r--src/platform/cocoa/window.m102
-rw-r--r--src/platform/posix/loop.c219
-rw-r--r--tests/common/test_loop.c248
-rw-r--r--tests/meson.build8
8 files changed, 651 insertions, 24 deletions
diff --git a/docs/guide.md b/docs/guide.md
index ab30c3d..888e4ab 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -334,10 +334,29 @@ The loop never closes application file descriptors: delete the watch before
closing its descriptor. Ready callbacks may add or remove watches. Do not call
`yasl_loop_step()` or `yasl_loop_run()` recursively.
-Cocoa dispatch waits for native events and checks the loop nonblockingly
-afterward. An application FD alone does not currently wake an indefinite Cocoa
-dispatch; use a bounded timeout when integrating such sources. `loop_kick()`
-signals the loop pipe but is not a portable cross-thread event-posting API.
+Every backend's dispatch wakes when a loop descriptor becomes ready. Cocoa
+waits for native events and mirrors the loop's descriptors in a kqueue while it
+waits. If kqueue rejects a descriptor, Cocoa bounds the wait to 10 ms instead.
+
+Two calls work from any thread. `yasl_loop_kick()` only wakes the loop, and it
+is also safe in a signal handler. `yasl_loop_post()` queues a function to run
+on the owning thread during a later step or dispatch. Use it to hand results
+from worker threads to the loop:
+
+```c
+static void results_ready(void *ud)
+{
+ worker_drain(ud); /* Runs on the loop thread. */
+}
+/* On the worker thread; false means allocation failed. */
+yasl_loop_post(loop, results_ready, worker);
+```
+
+`yasl_loop_watch_signals()` delivers POSIX signals to a callback on the loop
+thread. It installs a process-wide handler for each signal, with `SA_RESTART`,
+and restores the previous action on unwatch. YASL leaves thread signal masks
+alone: a watched signal must stay unblocked in at least one thread. Only one
+loop can watch a given signal at a time.
## Native interoperability
diff --git a/include/yasl/app.h b/include/yasl/app.h
index bf6de9c..b7e1c13 100644
--- a/include/yasl/app.h
+++ b/include/yasl/app.h
@@ -98,7 +98,8 @@ YASL_API void yasl_surface_request_frame(struct yasl_surface *s);
* @param code Application-defined message code (yasl_user_event.code).
* @param payload Arbitrary pointer passed through to the event.
* @param size Optional payload size.
- * @return true if queued successfully, false if queue is full or memory failed.
+ * @return true if queued; false if memory failed or d is not run by yasl_run().
+ * Applications that run their own loop use yasl_loop_post() instead.
*/
YASL_API bool yasl_post_event(struct yasl_display *d, struct yasl_surface *s,
uint32_t code, void *payload, size_t size);
diff --git a/include/yasl/loop.h b/include/yasl/loop.h
index 9faa8f7..a8e9974 100644
--- a/include/yasl/loop.h
+++ b/include/yasl/loop.h
@@ -3,7 +3,8 @@
* Optional event loop, file watches, and timers.
*
* Use the loop's owning thread for all calls and callbacks, except
- * yasl_loop_now_ns(). A display borrows a supplied loop. For window applications,
+ * yasl_loop_now_ns(), yasl_loop_kick(), and yasl_loop_post(), which any thread
+ * may call. A display borrows a supplied loop. For window applications,
* drive yasl_display_dispatch(), drain input, then yasl_display_flush(); the
* standalone loop_run() function does not replace this cycle. Timer intervals are
* nanoseconds; timeout_ms arguments are milliseconds. Do not recursively step/run
@@ -29,10 +30,11 @@ extern "C" {
*/
YASL_API struct yasl_loop *yasl_loop_create(void);
/**
- * Release a loop, its watches, and its timers.
+ * Release a loop, its watches, its timers, and its signal watch.
* @param l Loop, or NULL for no action.
* Close displays borrowing this loop first. Must not run during a loop callback
- * or an active step/run. Watched file descriptors are not closed.
+ * or an active step/run. Watched file descriptors are not closed. Posted
+ * functions that have not run are dropped; no thread may still post or kick.
*/
YASL_API void yasl_loop_destroy(struct yasl_loop *l);
/**
@@ -59,13 +61,62 @@ YASL_API int yasl_loop_step(struct yasl_loop *l, int timeout_ms);
*/
YASL_API void yasl_loop_quit(struct yasl_loop *l);
/**
- * Signal the loop's wake pipe without dispatching callbacks.
+ * Wake a loop that is waiting, without running any callback.
* @param l Loop, or NULL for no action.
- * Multiple signals may coalesce. This does not schedule a redraw or enqueue an
- * event, and it does not provide a cross-thread application messaging contract.
+ * May be called from any thread and from a signal handler. The loop must stay
+ * alive until the call returns. A waiting yasl_loop_step(), yasl_loop_run(), or
+ * yasl_display_dispatch() on this loop returns early; a loop that is not
+ * waiting returns early from its next wait instead. Kicks coalesce. This does
+ * not schedule a redraw or enqueue an event; use yasl_loop_post() to run code
+ * on the owning thread.
*/
YASL_API void yasl_loop_kick(struct yasl_loop *l);
/**
+ * Run a function on the loop's owning thread.
+ * @param l Loop.
+ * @param fn Required function, called once with ud during a later step.
+ * @param ud Borrowed userdata until fn runs.
+ * @return true if queued; false for NULL arguments, a loop without a wake
+ * pipe, or allocation failure.
+ * May be called from any thread, but not from a signal handler. Wakes the loop
+ * like yasl_loop_kick(). Functions run in posting order, during a
+ * yasl_loop_step() or display dispatch on the owning thread. They may post
+ * again; a function posted while posts are running waits for a later step.
+ * Stop posting before yasl_loop_destroy(), which drops functions that have
+ * not run.
+ */
+YASL_API bool yasl_loop_post(struct yasl_loop *l, void (*fn)(void *ud), void *ud);
+/**
+ * Deliver POSIX signals to a callback on the loop's owning thread.
+ * @param l Loop.
+ * @param signals Signal numbers to watch; the array is read during the call.
+ * @param n Number of entries, at least one.
+ * @param cb Required callback, given ud and one signal number per delivery.
+ * @param ud Borrowed userdata until the watch is removed.
+ * @return true if every signal is watched; false with errno set and no
+ * dispositions changed. EINVAL means a NULL or invalid argument, including
+ * SIGKILL and SIGSTOP. EBUSY means another loop watches one of the signals.
+ * Replaces this loop's previous watch, if any. Installs one process-wide
+ * handler per signal with SA_RESTART and an empty handler mask, and saves the
+ * previous action. The handler only records the signal and wakes the loop,
+ * so it may run on any thread. YASL does not change any thread's signal mask:
+ * a signal blocked in every thread stays pending and is not delivered. Caught
+ * signals reset to their default action across exec, so spawned programs
+ * do not inherit the handler. Repeated deliveries of one signal before the
+ * callback runs coalesce into one callback. SIGCHLD reports stopped and
+ * continued children as well as exits. Callbacks run during yasl_loop_step()
+ * or display dispatch, and may remove the watch.
+ */
+YASL_API bool yasl_loop_watch_signals(struct yasl_loop *l, const int *signals, int n,
+ void (*cb)(void *ud, int signo), void *ud);
+/**
+ * Stop watching signals and restore their previous actions.
+ * @param l Loop, or NULL for no action.
+ * Does nothing if the loop watches no signals. Signals that arrived but have
+ * not reached the callback are dropped. yasl_loop_destroy() also unwatches.
+ */
+YASL_API void yasl_loop_unwatch_signals(struct yasl_loop *l);
+/**
* Read the monotonic clock used by YASL timers and timing results.
* @return Nanoseconds from CLOCK_MONOTONIC, unrelated to wall-clock time.
* May be called from any thread.
diff --git a/src/common/loop.h b/src/common/loop.h
index 663417a..60c5b64 100644
--- a/src/common/loop.h
+++ b/src/common/loop.h
@@ -3,7 +3,12 @@
#include "yasl/loop.h"
+struct pollfd;
+
int yasl_loop_next_timeout_ms(const struct yasl_loop *l);
+/* The loop's current poll set, valid until the next watch change. Backends
+ * that wait on native events use it to wake on loop readiness. */
+int yasl_loop_poll_set(const struct yasl_loop *l, const struct pollfd **fds);
/* Whether id is armed: added, not deleted, and not retired by its callback. */
bool yasl_loop_timer_armed(const struct yasl_loop *l, yasl_timer_id id);
diff --git a/src/platform/cocoa/window.m b/src/platform/cocoa/window.m
index f1f0f03..811a7c6 100644
--- a/src/platform/cocoa/window.m
+++ b/src/platform/cocoa/window.m
@@ -13,11 +13,18 @@
#include <assert.h>
#include <math.h>
+#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
+#include <sys/event.h>
+#include <unistd.h>
#define LINK_IDLE_FRAMES 8
+/* Application-defined event subtype that only wakes a waiting dispatch. */
+#define LOOP_WAKE_SUBTYPE 0x5953
+/* Wait bound while some loop descriptor cannot be watched with kqueue. */
+#define LOOP_FALLBACK_MS 10
#define SCROLL_POINTS_PER_LINE 10.0
enum {
@@ -1119,6 +1126,54 @@ static void pump_window(struct cocoa_surface *w)
@autoreleasepool { render_and_commit(w); }
}
+static void loop_ready(CFFileDescriptorRef fd, CFOptionFlags flags, void *info)
+{
+ (void)fd;
+ (void)flags;
+ (void)info;
+ NSEvent *wake = [NSEvent otherEventWithType:NSEventTypeApplicationDefined
+ location:NSZeroPoint
+ modifierFlags:0
+ timestamp:0
+ windowNumber:0
+ context:nil
+ subtype:LOOP_WAKE_SUBTYPE
+ data1:0
+ data2:0];
+ [NSApp postEvent:wake atStart:YES];
+}
+
+/* A kqueue mirroring the loop's poll set, or -1. complete is false when some
+ * descriptor could not be registered. */
+static int loop_kqueue(struct yasl_loop *loop, bool *complete)
+{
+ *complete = true;
+ int kq = kqueue();
+ if (kq < 0) {
+ *complete = false;
+ return -1;
+ }
+ const struct pollfd *fds;
+ int n = yasl_loop_poll_set(loop, &fds);
+ for (int i = 0; i < n; i++) {
+ struct kevent change;
+ if (fds[i].events & POLLIN) {
+ EV_SET(&change, fds[i].fd, EVFILT_READ, EV_ADD, 0, 0, NULL);
+ if (kevent(kq, &change, 1, NULL, 0, NULL) < 0) *complete = false;
+ }
+ if (fds[i].events & POLLOUT) {
+ EV_SET(&change, fds[i].fd, EVFILT_WRITE, EV_ADD, 0, 0, NULL);
+ if (kevent(kq, &change, 1, NULL, 0, NULL) < 0) *complete = false;
+ }
+ }
+ return kq;
+}
+
+static bool is_loop_wake(NSEvent *ev)
+{
+ return ev.type == NSEventTypeApplicationDefined && ev.subtype == LOOP_WAKE_SUBTYPE;
+}
+
int cocoa_display_dispatch_timeout(struct cocoa_display *d, int timeout_ms)
{
assert([NSThread isMainThread]);
@@ -1143,6 +1198,30 @@ int cocoa_display_dispatch_timeout(struct cocoa_display *d, int timeout_ms)
}
}
+ /* AppKit waits on Mach ports, not on loop descriptors. While waiting,
+ * a run loop source on a kqueue of the loop's descriptors posts a wake
+ * event, so kicks, posts, signals, and watches end the wait. */
+ CFFileDescriptorRef wake_fd = NULL;
+ CFRunLoopSourceRef wake_source = NULL;
+ if (timeout_ms != 0 && d->loop) {
+ bool complete;
+ int kq = loop_kqueue(d->loop, &complete);
+ if (kq >= 0) {
+ wake_fd = CFFileDescriptorCreate(kCFAllocatorDefault, kq, true, loop_ready, NULL);
+ if (!wake_fd) close(kq);
+ }
+ if (wake_fd) {
+ CFFileDescriptorEnableCallBacks(wake_fd, kCFFileDescriptorReadCallBack);
+ wake_source = CFFileDescriptorCreateRunLoopSource(kCFAllocatorDefault, wake_fd, 0);
+ }
+ if (wake_source)
+ CFRunLoopAddSource(CFRunLoopGetCurrent(), wake_source, kCFRunLoopDefaultMode);
+ else
+ complete = false;
+ if (!complete && (timeout_ms < 0 || timeout_ms > LOOP_FALLBACK_MS))
+ timeout_ms = LOOP_FALLBACK_MS;
+ }
+
NSDate *limitDate;
if (timeout_ms == 0)
limitDate = [NSDate distantPast];
@@ -1156,19 +1235,24 @@ int cocoa_display_dispatch_timeout(struct cocoa_display *d, int timeout_ms)
untilDate:limitDate
inMode:NSDefaultRunLoopMode
dequeue:YES];
- if (ev) {
- count++;
- [NSApp sendEvent:ev];
- [NSApp updateWindows];
-
- while ((ev = [NSApp nextEventMatchingMask:NSEventMaskAny
- untilDate:[NSDate distantPast]
- inMode:NSDefaultRunLoopMode
- dequeue:YES])) {
+ if (wake_source) {
+ CFRunLoopRemoveSource(CFRunLoopGetCurrent(), wake_source, kCFRunLoopDefaultMode);
+ CFRelease(wake_source);
+ }
+ if (wake_fd) {
+ CFFileDescriptorInvalidate(wake_fd);
+ CFRelease(wake_fd);
+ }
+ while (ev) {
+ if (!is_loop_wake(ev)) {
count++;
[NSApp sendEvent:ev];
[NSApp updateWindows];
}
+ ev = [NSApp nextEventMatchingMask:NSEventMaskAny
+ untilDate:[NSDate distantPast]
+ inMode:NSDefaultRunLoopMode
+ dequeue:YES];
}
}
diff --git a/src/platform/posix/loop.c b/src/platform/posix/loop.c
index 88454d8..3c3d730 100644
--- a/src/platform/posix/loop.c
+++ b/src/platform/posix/loop.c
@@ -1,10 +1,14 @@
#define _GNU_SOURCE
+#define _DARWIN_C_SOURCE
#define _POSIX_C_SOURCE 200809L
#include "common/loop.h"
+#include "platform/sync.h"
#include <errno.h>
#include <fcntl.h>
#include <poll.h>
+#include <signal.h>
+#include <stdatomic.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
@@ -31,6 +35,21 @@ struct yasl_timer_source {
bool deleted;
};
+struct yasl_post {
+ void (*fn)(void *ud);
+ void *ud;
+ struct yasl_post *next;
+};
+
+/* Signals this loop watches; the previous actions are restored on unwatch. */
+struct yasl_signal_watch {
+ void (*cb)(void *ud, int signo);
+ void *ud;
+ int count;
+ int signals[NSIG];
+ struct sigaction old[NSIG];
+};
+
struct yasl_loop {
struct yasl_fd_source *fds;
struct pollfd *pollfds;
@@ -47,8 +66,21 @@ struct yasl_loop {
int timer_capacity;
uint32_t next_timer_id;
int timer_firing_depth;
+
+ yasl_mutex post_mutex;
+ struct yasl_post *post_head;
+ struct yasl_post *post_tail;
+
+ struct yasl_signal_watch *signals;
};
+/* Handlers run on any thread, so they only touch lock-free atomics: they mark
+ * the signal pending and write to the owning loop's wake pipe. The pipe is
+ * stored as fd + 1 so that the zero initializer means no pipe. */
+static atomic_int signal_wake_fd[NSIG];
+static atomic_bool signal_pending[NSIG];
+static struct yasl_loop *signal_owner[NSIG];
+
uint64_t yasl_loop_now_ns(void)
{
struct timespec ts;
@@ -56,13 +88,41 @@ uint64_t yasl_loop_now_ns(void)
return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
}
+static void run_posts(struct yasl_loop *l)
+{
+ yasl_mutex_lock(&l->post_mutex);
+ struct yasl_post *p = l->post_head;
+ l->post_head = l->post_tail = NULL;
+ yasl_mutex_unlock(&l->post_mutex);
+ while (p) {
+ struct yasl_post *next = p->next;
+ p->fn(p->ud);
+ free(p);
+ p = next;
+ }
+}
+
+static void run_signals(struct yasl_loop *l)
+{
+ /* A callback may unwatch, so re-check the watch before each delivery. */
+ for (int signo = 1; signo < NSIG; signo++) {
+ struct yasl_signal_watch *w = l->signals;
+ if (!w) return;
+ if (signal_owner[signo] != l || !atomic_exchange(&signal_pending[signo], false))
+ continue;
+ w->cb(w->ud, signo);
+ }
+}
+
static int loop_wake_cb(int fd, short revents, void *ud)
{
(void)revents;
- (void)ud;
char buf[128];
ssize_t n;
while ((n = read(fd, buf, sizeof(buf))) > 0 || (n < 0 && errno == EINTR)) {}
+ struct yasl_loop *l = ud;
+ run_posts(l);
+ run_signals(l);
return 0;
}
@@ -81,6 +141,13 @@ struct yasl_loop *yasl_loop_create(void)
return NULL;
}
+ if (yasl_mutex_init(&l->post_mutex) != 0) {
+ free(l->fds);
+ free(l->pollfds);
+ free(l);
+ return NULL;
+ }
+
l->next_id = 1;
l->next_timer_id = 1;
l->wake_rfd = -1;
@@ -114,6 +181,13 @@ struct yasl_loop *yasl_loop_create(void)
void yasl_loop_destroy(struct yasl_loop *l)
{
if (!l) return;
+ yasl_loop_unwatch_signals(l);
+ for (struct yasl_post *p = l->post_head; p;) {
+ struct yasl_post *next = p->next;
+ free(p);
+ p = next;
+ }
+ yasl_mutex_destroy(&l->post_mutex);
if (l->wake_watch > 0)
yasl_loop_del_fd(l, l->wake_watch);
if (l->wake_rfd >= 0)
@@ -184,14 +258,141 @@ int yasl_loop_set_fd_events(struct yasl_loop *l, int id, short events)
return -1;
}
-void yasl_loop_kick(struct yasl_loop *l)
+static void wake_write(int fd)
{
- if (!l || l->wake_wfd < 0) return;
+ /* A full pipe already wakes the loop, so EAGAIN needs no retry. */
+ int saved = errno;
char b = 1;
ssize_t n;
do {
- n = write(l->wake_wfd, &b, 1);
+ n = write(fd, &b, 1);
} while (n < 0 && errno == EINTR);
+ errno = saved;
+}
+
+void yasl_loop_kick(struct yasl_loop *l)
+{
+ if (!l || l->wake_wfd < 0) return;
+ wake_write(l->wake_wfd);
+}
+
+bool yasl_loop_post(struct yasl_loop *l, void (*fn)(void *ud), void *ud)
+{
+ if (!l || !fn || l->wake_wfd < 0) return false;
+ struct yasl_post *p = malloc(sizeof(*p));
+ if (!p) return false;
+ *p = (struct yasl_post){.fn = fn, .ud = ud};
+ yasl_mutex_lock(&l->post_mutex);
+ if (l->post_tail)
+ l->post_tail->next = p;
+ else
+ l->post_head = p;
+ l->post_tail = p;
+ yasl_mutex_unlock(&l->post_mutex);
+ wake_write(l->wake_wfd);
+ return true;
+}
+
+static void signal_handler(int signo)
+{
+ atomic_store(&signal_pending[signo], true);
+ int fd = atomic_load(&signal_wake_fd[signo]) - 1;
+ if (fd >= 0) wake_write(fd);
+}
+
+static bool signal_valid(int signo)
+{
+ return signo > 0 && signo < NSIG && signo != SIGKILL && signo != SIGSTOP;
+}
+
+bool yasl_loop_watch_signals(struct yasl_loop *l, const int *signals, int n,
+ void (*cb)(void *ud, int signo), void *ud)
+{
+ if (!l || !signals || n <= 0 || !cb || l->wake_wfd < 0) {
+ errno = EINVAL;
+ return false;
+ }
+ bool wanted[NSIG] = {0};
+ for (int i = 0; i < n; i++) {
+ if (!signal_valid(signals[i])) {
+ errno = EINVAL;
+ return false;
+ }
+ if (signal_owner[signals[i]] && signal_owner[signals[i]] != l) {
+ errno = EBUSY;
+ return false;
+ }
+ wanted[signals[i]] = true;
+ }
+ struct yasl_signal_watch *w = calloc(1, sizeof(*w));
+ if (!w) return false;
+ w->cb = cb;
+ w->ud = ud;
+
+ /* Keep the old watch's saved actions for signals that stay watched, so
+ * that unwatch restores what the application had before any watch. */
+ struct yasl_signal_watch *old = l->signals;
+ struct sigaction sa = {.sa_handler = signal_handler, .sa_flags = SA_RESTART};
+ sigemptyset(&sa.sa_mask);
+ for (int signo = 1; signo < NSIG; signo++) {
+ if (!wanted[signo]) continue;
+ int prev = -1;
+ for (int i = 0; old && i < old->count; i++)
+ if (old->signals[i] == signo) prev = i;
+ if (prev >= 0) {
+ w->old[w->count] = old->old[prev];
+ } else {
+ atomic_store(&signal_pending[signo], false);
+ atomic_store(&signal_wake_fd[signo], l->wake_wfd + 1);
+ if (sigaction(signo, &sa, &w->old[w->count]) != 0) {
+ int err = errno;
+ atomic_store(&signal_wake_fd[signo], 0);
+ for (int i = 0; i < w->count; i++) {
+ int done = w->signals[i];
+ bool kept = false;
+ for (int j = 0; old && j < old->count; j++)
+ if (old->signals[j] == done) kept = true;
+ if (kept) continue;
+ sigaction(done, &w->old[i], NULL);
+ atomic_store(&signal_wake_fd[done], 0);
+ }
+ free(w);
+ errno = err;
+ return false;
+ }
+ }
+ w->signals[w->count++] = signo;
+ }
+
+ /* Restore signals the old watch had that the new one drops. */
+ for (int i = 0; old && i < old->count; i++) {
+ int signo = old->signals[i];
+ if (wanted[signo]) continue;
+ sigaction(signo, &old->old[i], NULL);
+ atomic_store(&signal_wake_fd[signo], 0);
+ atomic_store(&signal_pending[signo], false);
+ signal_owner[signo] = NULL;
+ }
+ for (int i = 0; i < w->count; i++)
+ signal_owner[w->signals[i]] = l;
+ free(old);
+ l->signals = w;
+ return true;
+}
+
+void yasl_loop_unwatch_signals(struct yasl_loop *l)
+{
+ if (!l || !l->signals) return;
+ struct yasl_signal_watch *w = l->signals;
+ l->signals = NULL;
+ for (int i = 0; i < w->count; i++) {
+ int signo = w->signals[i];
+ sigaction(signo, &w->old[i], NULL);
+ atomic_store(&signal_wake_fd[signo], 0);
+ atomic_store(&signal_pending[signo], false);
+ signal_owner[signo] = NULL;
+ }
+ free(w);
}
static void timer_cleanup_deleted(struct yasl_loop *l)
@@ -215,6 +416,16 @@ bool yasl_loop_timer_armed(const struct yasl_loop *l, yasl_timer_id id)
return false;
}
+int yasl_loop_poll_set(const struct yasl_loop *l, const struct pollfd **fds)
+{
+ if (!l) {
+ *fds = NULL;
+ return 0;
+ }
+ *fds = l->pollfds;
+ return l->count;
+}
+
int yasl_loop_next_timeout_ms(const struct yasl_loop *l)
{
if (!l || l->timer_count <= 0) return -1;
diff --git a/tests/common/test_loop.c b/tests/common/test_loop.c
new file mode 100644
index 0000000..f982e5e
--- /dev/null
+++ b/tests/common/test_loop.c
@@ -0,0 +1,248 @@
+#define _GNU_SOURCE
+#define _DARWIN_C_SOURCE
+#include "yasl/loop.h"
+
+#include <assert.h>
+#include <errno.h>
+#include <pthread.h>
+#include <signal.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+static void sleep_ms(int ms)
+{
+ struct timespec ts = {.tv_sec = ms / 1000, .tv_nsec = (long)(ms % 1000) * 1000000L};
+ nanosleep(&ts, NULL);
+}
+
+/* Fail instead of hanging when a wake never arrives. */
+static void on_alarm(int signo)
+{
+ (void)signo;
+ static const char msg[] = "loop test timed out\n";
+ ssize_t ignored = write(STDERR_FILENO, msg, sizeof(msg) - 1);
+ (void)ignored;
+ _exit(1);
+}
+
+struct post_log {
+ struct yasl_loop *loop;
+ char order[8];
+ int count;
+};
+
+static void post_a(void *ud);
+
+static void post_c(void *ud)
+{
+ struct post_log *log = ud;
+ log->order[log->count++] = 'c';
+}
+
+static void post_a(void *ud)
+{
+ struct post_log *log = ud;
+ log->order[log->count++] = 'a';
+ assert(yasl_loop_post(log->loop, post_c, log));
+}
+
+static void post_b(void *ud)
+{
+ struct post_log *log = ud;
+ log->order[log->count++] = 'b';
+}
+
+static void test_post_order(void)
+{
+ struct yasl_loop *l = yasl_loop_create();
+ assert(l);
+ assert(!yasl_loop_post(NULL, post_b, NULL));
+ assert(!yasl_loop_post(l, NULL, NULL));
+
+ struct post_log log = {.loop = l};
+ assert(yasl_loop_post(l, post_a, &log));
+ assert(yasl_loop_post(l, post_b, &log));
+ assert(yasl_loop_step(l, 0) >= 1);
+ /* A post made while posts run waits for a later step. */
+ assert(log.count == 2 && memcmp(log.order, "ab", 2) == 0);
+ assert(yasl_loop_step(l, 0) >= 1);
+ assert(log.count == 3 && log.order[2] == 'c');
+
+ /* Destroy drops posts that never ran. */
+ assert(yasl_loop_post(l, post_b, &log));
+ yasl_loop_destroy(l);
+ assert(log.count == 3);
+}
+
+struct thread_ctx {
+ struct yasl_loop *loop;
+ bool kick;
+ int signo;
+ _Atomic bool ran;
+};
+
+static void mark_ran(void *ud)
+{
+ struct thread_ctx *ctx = ud;
+ ctx->ran = true;
+}
+
+static void *wake_thread(void *ud)
+{
+ struct thread_ctx *ctx = ud;
+ sleep_ms(30);
+ if (ctx->signo)
+ kill(getpid(), ctx->signo);
+ else if (ctx->kick)
+ yasl_loop_kick(ctx->loop);
+ else
+ assert(yasl_loop_post(ctx->loop, mark_ran, ctx));
+ return NULL;
+}
+
+static void test_cross_thread(void)
+{
+ struct yasl_loop *l = yasl_loop_create();
+ assert(l);
+
+ struct thread_ctx post = {.loop = l};
+ pthread_t t;
+ assert(pthread_create(&t, NULL, wake_thread, &post) == 0);
+ while (!post.ran)
+ assert(yasl_loop_step(l, -1) >= 0);
+ pthread_join(t, NULL);
+
+ /* A kick ends an indefinite wait without running anything. */
+ struct thread_ctx kick = {.loop = l, .kick = true};
+ assert(pthread_create(&t, NULL, wake_thread, &kick) == 0);
+ assert(yasl_loop_step(l, -1) >= 1);
+ pthread_join(t, NULL);
+
+ yasl_loop_destroy(l);
+}
+
+struct signal_log {
+ struct yasl_loop *loop;
+ int seen[NSIG];
+ int total;
+ bool unwatch;
+};
+
+static void on_signal(void *ud, int signo)
+{
+ struct signal_log *log = ud;
+ assert(signo > 0 && signo < NSIG);
+ log->seen[signo]++;
+ log->total++;
+ if (log->unwatch)
+ yasl_loop_unwatch_signals(log->loop);
+}
+
+static volatile sig_atomic_t previous_handler_hits;
+
+static void previous_handler(int signo)
+{
+ (void)signo;
+ previous_handler_hits++;
+}
+
+static void step_until(struct yasl_loop *l, const int *counter, int want)
+{
+ while (*counter < want)
+ assert(yasl_loop_step(l, -1) >= 0);
+}
+
+static void test_signals(void)
+{
+ struct sigaction prev = {.sa_handler = previous_handler};
+ sigemptyset(&prev.sa_mask);
+ assert(sigaction(SIGUSR2, &prev, NULL) == 0);
+
+ struct yasl_loop *l = yasl_loop_create();
+ struct yasl_loop *other = yasl_loop_create();
+ assert(l && other);
+ struct signal_log log = {.loop = l};
+
+ const int bad[] = {SIGKILL};
+ errno = 0;
+ assert(!yasl_loop_watch_signals(l, bad, 1, on_signal, &log) && errno == EINVAL);
+ const int sigs[] = {SIGUSR1, SIGUSR2, SIGCHLD};
+ errno = 0;
+ assert(!yasl_loop_watch_signals(l, sigs, 0, on_signal, &log) && errno == EINVAL);
+ errno = 0;
+ assert(!yasl_loop_watch_signals(l, sigs, 3, NULL, &log) && errno == EINVAL);
+ assert(yasl_loop_watch_signals(l, sigs, 3, on_signal, &log));
+
+ /* One signal belongs to one loop at a time. */
+ errno = 0;
+ assert(!yasl_loop_watch_signals(other, sigs, 1, on_signal, &log) && errno == EBUSY);
+
+ /* Deliveries before the callback runs coalesce per signal. */
+ raise(SIGUSR1);
+ raise(SIGUSR1);
+ raise(SIGUSR2);
+ assert(yasl_loop_step(l, 0) >= 1);
+ assert(log.seen[SIGUSR1] == 1 && log.seen[SIGUSR2] == 1);
+ assert(previous_handler_hits == 0);
+
+ /* A signal sent from another thread wakes an indefinite wait. */
+ struct thread_ctx ctx = {.loop = l, .signo = SIGUSR1};
+ pthread_t t;
+ assert(pthread_create(&t, NULL, wake_thread, &ctx) == 0);
+ step_until(l, &log.seen[SIGUSR1], 2);
+ pthread_join(t, NULL);
+
+ /* SIGCHLD arrives on the loop thread when a child exits. */
+ pid_t child = fork();
+ assert(child >= 0);
+ if (child == 0) _exit(0);
+ step_until(l, &log.seen[SIGCHLD], 1);
+ int status;
+ assert(waitpid(child, &status, 0) == child);
+
+ /* Replacing the watch restores the signals it drops. */
+ const int only_usr1[] = {SIGUSR1};
+ assert(yasl_loop_watch_signals(l, only_usr1, 1, on_signal, &log));
+ raise(SIGUSR2);
+ assert(previous_handler_hits == 1);
+ assert(yasl_loop_watch_signals(other, (const int[]){SIGUSR2}, 1, on_signal, &log));
+ yasl_loop_unwatch_signals(other);
+ assert(previous_handler_hits == 1);
+ raise(SIGUSR2);
+ assert(previous_handler_hits == 2);
+
+ /* A callback may unwatch; the previous default action comes back. */
+ log.unwatch = true;
+ raise(SIGUSR1);
+ step_until(l, &log.seen[SIGUSR1], 3);
+ struct sigaction now;
+ assert(sigaction(SIGUSR1, NULL, &now) == 0);
+ assert(now.sa_handler == SIG_DFL);
+ yasl_loop_unwatch_signals(l);
+
+ /* Destroy unwatches. */
+ log.unwatch = false;
+ assert(yasl_loop_watch_signals(l, only_usr1, 1, on_signal, &log));
+ yasl_loop_destroy(l);
+ assert(sigaction(SIGUSR1, NULL, &now) == 0);
+ assert(now.sa_handler == SIG_DFL);
+ yasl_loop_destroy(other);
+
+ struct sigaction dfl = {.sa_handler = SIG_DFL};
+ sigemptyset(&dfl.sa_mask);
+ sigaction(SIGUSR2, &dfl, NULL);
+}
+
+int main(void)
+{
+ signal(SIGALRM, on_alarm);
+ alarm(10);
+ test_post_order();
+ test_cross_thread();
+ test_signals();
+ printf("Loop tests passed.\n");
+ return 0;
+}
diff --git a/tests/meson.build b/tests/meson.build
index 980f5db..f072d3f 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -109,6 +109,14 @@ timer_test = executable(
)
test('timers', timer_test)
+loop_test = executable(
+ 'yasl-loop-test',
+ 'common/test_loop.c',
+ include_directories: inc,
+ dependencies: [yasl_dep, dependency('threads')],
+)
+test('loop', loop_test)
+
app_test = executable(
'yasl-app-test',
'common/test_app.c',