From f5e4ad84a2007dc02c2feab71e5dc7b8c595831d Mon Sep 17 00:00:00 2001 From: Aestylis Date: Sat, 20 Jun 2026 21:49:18 -0700 Subject: [PATCH 1/6] feat(input): add lock-free SPMC keycode ring buffer Co-Authored-By: Claude Opus 4.8 --- Makefile | 5 ++- include/platform/key_ring.h | 53 ++++++++++++++++++++++ tests/test_key_ring.c | 90 +++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 include/platform/key_ring.h create mode 100644 tests/test_key_ring.c diff --git a/Makefile b/Makefile index bc3fa3fa..bad41df5 100644 --- a/Makefile +++ b/Makefile @@ -204,7 +204,10 @@ $(BUILDDIR)/test_config: $(TESTDIR)/test_config.c $(CONFIG_TEST_DEPS) | $(OBJDIR $(BUILDDIR)/test_memory: $(TESTDIR)/test_memory.c $(MEMORY_TEST_DEPS) | $(OBJDIR) $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) -TEST_BINARIES = $(BUILDDIR)/test_config $(BUILDDIR)/test_memory +$(BUILDDIR)/test_key_ring: $(TESTDIR)/test_key_ring.c | $(OBJDIR) + $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) + +TEST_BINARIES = $(BUILDDIR)/test_config $(BUILDDIR)/test_memory $(BUILDDIR)/test_key_ring test: $(TEST_BINARIES) @echo "Running tests..." diff --git a/include/platform/key_ring.h b/include/platform/key_ring.h new file mode 100644 index 00000000..be352bd6 --- /dev/null +++ b/include/platform/key_ring.h @@ -0,0 +1,53 @@ +#ifndef BONGOCAT_KEY_RING_H +#define BONGOCAT_KEY_RING_H + +// Single-producer / multi-consumer broadcast ring for keycodes. +// Producer: the input child process. Consumers: each monitor's animation +// thread, each with its own process-local `tail`. Codes are never removed; +// a consumer lapped by more than KEY_RING_SIZE drops the oldest unread codes. +// Lives in MAP_SHARED memory so all processes see the same buffer. + +#include + +#define KEY_RING_SIZE 64u // must be a power of two + +typedef struct { + atomic_uint head; // monotonic publish counter (producer) + atomic_int codes[KEY_RING_SIZE]; // codes[seq & (KEY_RING_SIZE-1)] +} key_ring_t; + +static inline void key_ring_init(key_ring_t *r) { + atomic_store_explicit(&r->head, 0u, memory_order_relaxed); + for (unsigned i = 0; i < KEY_RING_SIZE; i++) { + atomic_store_explicit(&r->codes[i], 0, memory_order_relaxed); + } +} + +static inline void key_ring_push(key_ring_t *r, int code) { + unsigned head = atomic_load_explicit(&r->head, memory_order_relaxed); + atomic_store_explicit(&r->codes[head & (KEY_RING_SIZE - 1)], code, + memory_order_relaxed); + atomic_store_explicit(&r->head, head + 1u, memory_order_release); +} + +// Copies up to out_cap codes (oldest first) into out, advances *tail, and +// returns the count written. If the consumer has been lapped (more than +// KEY_RING_SIZE codes pending), the oldest are dropped first. +static inline unsigned key_ring_drain(const key_ring_t *r, unsigned *tail, + int *out, unsigned out_cap) { + unsigned head = atomic_load_explicit(&r->head, memory_order_acquire); + unsigned avail = head - *tail; // unsigned wrap-safe + if (avail > KEY_RING_SIZE) { + *tail = head - KEY_RING_SIZE; // drop oldest + avail = KEY_RING_SIZE; + } + unsigned n = (avail < out_cap) ? avail : out_cap; + for (unsigned i = 0; i < n; i++) { + out[i] = atomic_load_explicit(&r->codes[(*tail + i) & (KEY_RING_SIZE - 1)], + memory_order_relaxed); + } + *tail += n; + return n; +} + +#endif // BONGOCAT_KEY_RING_H diff --git a/tests/test_key_ring.c b/tests/test_key_ring.c new file mode 100644 index 00000000..5513c5d2 --- /dev/null +++ b/tests/test_key_ring.c @@ -0,0 +1,90 @@ +// Unit tests for the SPMC broadcast key ring +#define _POSIX_C_SOURCE 200809L + +#include "../include/platform/key_ring.h" + +#include + +static int tests_passed = 0; +static int tests_failed = 0; + +#define TEST_ASSERT(cond, msg) \ + do { \ + if (cond) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, msg); \ + } \ + } while (0) + +// Basic push then drain returns codes in order. +static void test_push_drain_order(void) { + printf("test_push_drain_order...\n"); + key_ring_t r; + key_ring_init(&r); + unsigned tail = 0; + key_ring_push(&r, 30); // A (left) + key_ring_push(&r, 38); // L (right) + int out[KEY_RING_SIZE]; + unsigned n = key_ring_drain(&r, &tail, out, KEY_RING_SIZE); + TEST_ASSERT(n == 2, "drained two codes"); + TEST_ASSERT(out[0] == 30, "first code is 30"); + TEST_ASSERT(out[1] == 38, "second code is 38"); + unsigned n2 = key_ring_drain(&r, &tail, out, KEY_RING_SIZE); + TEST_ASSERT(n2 == 0, "second drain is empty"); +} + +// Two independent consumers each see the full stream (broadcast). +static void test_two_consumers(void) { + printf("test_two_consumers...\n"); + key_ring_t r; + key_ring_init(&r); + unsigned tail_a = 0, tail_b = 0; + for (int i = 0; i < 5; i++) + key_ring_push(&r, 100 + i); + int out[KEY_RING_SIZE]; + unsigned na = key_ring_drain(&r, &tail_a, out, KEY_RING_SIZE); + unsigned nb = key_ring_drain(&r, &tail_b, out, KEY_RING_SIZE); + TEST_ASSERT(na == 5, "consumer A sees all 5"); + TEST_ASSERT(nb == 5, "consumer B sees all 5 independently"); +} + +// Wrap-around past KEY_RING_SIZE works. +static void test_wraparound(void) { + printf("test_wraparound...\n"); + key_ring_t r; + key_ring_init(&r); + unsigned tail = 0; + int out[KEY_RING_SIZE]; + for (int i = 0; i < KEY_RING_SIZE + 10; i++) { + key_ring_push(&r, i); + unsigned n = key_ring_drain(&r, &tail, out, KEY_RING_SIZE); + TEST_ASSERT(n == 1 && out[0] == i, "keep-up consumer reads each in turn"); + } +} + +// A lapped consumer drops oldest, keeps newest KEY_RING_SIZE, never more. +static void test_overflow_drop_oldest(void) { + printf("test_overflow_drop_oldest...\n"); + key_ring_t r; + key_ring_init(&r); + unsigned tail = 0; + for (int i = 0; i < KEY_RING_SIZE + 5; i++) // never drained -> lapped by 5 + key_ring_push(&r, i); + int out[KEY_RING_SIZE]; + unsigned n = key_ring_drain(&r, &tail, out, KEY_RING_SIZE); + TEST_ASSERT(n == KEY_RING_SIZE, "drain clamps to ring size"); + TEST_ASSERT(out[0] == 5, "oldest 5 dropped, first kept is 5"); + TEST_ASSERT(out[KEY_RING_SIZE - 1] == KEY_RING_SIZE + 4, "last kept is newest"); +} + +int main(void) { + printf("=== Key Ring Tests ===\n"); + test_push_drain_order(); + test_two_consumers(); + test_wraparound(); + test_overflow_drop_oldest(); + printf("\nResults: %d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed > 0 ? 1 : 0; +} From 4971e4f78899eefe0e73af3ae6c1f848b8d9b7cb Mon Sep 17 00:00:00 2001 From: Aestylis Date: Sat, 20 Jun 2026 21:50:10 -0700 Subject: [PATCH 2/6] feat(animation): add pure paw-state to frame mapping Co-Authored-By: Claude Opus 4.8 --- Makefile | 5 +++- include/graphics/paw_frame.h | 25 ++++++++++++++++ tests/test_paw_frame.c | 55 ++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 include/graphics/paw_frame.h create mode 100644 tests/test_paw_frame.c diff --git a/Makefile b/Makefile index bad41df5..acc831a5 100644 --- a/Makefile +++ b/Makefile @@ -207,7 +207,10 @@ $(BUILDDIR)/test_memory: $(TESTDIR)/test_memory.c $(MEMORY_TEST_DEPS) | $(OBJDIR $(BUILDDIR)/test_key_ring: $(TESTDIR)/test_key_ring.c | $(OBJDIR) $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) -TEST_BINARIES = $(BUILDDIR)/test_config $(BUILDDIR)/test_memory $(BUILDDIR)/test_key_ring +$(BUILDDIR)/test_paw_frame: $(TESTDIR)/test_paw_frame.c | $(OBJDIR) + $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) + +TEST_BINARIES = $(BUILDDIR)/test_config $(BUILDDIR)/test_memory $(BUILDDIR)/test_key_ring $(BUILDDIR)/test_paw_frame test: $(TEST_BINARIES) @echo "Running tests..." diff --git a/include/graphics/paw_frame.h b/include/graphics/paw_frame.h new file mode 100644 index 00000000..f18d644c --- /dev/null +++ b/include/graphics/paw_frame.h @@ -0,0 +1,25 @@ +#ifndef BONGOCAT_PAW_FRAME_H +#define BONGOCAT_PAW_FRAME_H + +// Pure mapping from per-paw liveness to a cat frame index. +// Sleep handling is the caller's responsibility (checked before this). + +#include "core/bongocat.h" + +#include + +static inline int frame_from_paw_state(bool left_live, bool right_live, + int idle_frame) { + if (left_live && right_live) { + return BONGOCAT_FRAME_BOTH_DOWN; + } + if (left_live) { + return BONGOCAT_FRAME_LEFT_DOWN; + } + if (right_live) { + return BONGOCAT_FRAME_RIGHT_DOWN; + } + return idle_frame; +} + +#endif // BONGOCAT_PAW_FRAME_H diff --git a/tests/test_paw_frame.c b/tests/test_paw_frame.c new file mode 100644 index 00000000..cc631f7d --- /dev/null +++ b/tests/test_paw_frame.c @@ -0,0 +1,55 @@ +// Unit tests for paw-state -> frame derivation +#define _POSIX_C_SOURCE 200809L + +// Stub wayland-client types before including bongocat.h (matches test_memory.c) +struct wl_output; +struct zxdg_output_v1; +#define _WAYLAND_CLIENT_H +#define _XDG_OUTPUT_UNSTABLE_V1_CLIENT_PROTOCOL_H + +#include "../include/core/bongocat.h" +#include "../include/graphics/paw_frame.h" + +#include + +static int tests_passed = 0; +static int tests_failed = 0; + +#define TEST_ASSERT(cond, msg) \ + do { \ + if (cond) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, msg); \ + } \ + } while (0) + +static void test_combinations(void) { + printf("test_combinations...\n"); + int idle = BONGOCAT_FRAME_BOTH_UP; + TEST_ASSERT(frame_from_paw_state(false, false, idle) == BONGOCAT_FRAME_BOTH_UP, + "neither -> idle (both up)"); + TEST_ASSERT(frame_from_paw_state(true, false, idle) == BONGOCAT_FRAME_LEFT_DOWN, + "left only -> left down"); + TEST_ASSERT(frame_from_paw_state(false, true, idle) == BONGOCAT_FRAME_RIGHT_DOWN, + "right only -> right down"); + TEST_ASSERT(frame_from_paw_state(true, true, idle) == BONGOCAT_FRAME_BOTH_DOWN, + "both -> both down"); +} + +static void test_custom_idle_frame(void) { + printf("test_custom_idle_frame...\n"); + // A non-default idle_frame is honored when no paw is live. + TEST_ASSERT(frame_from_paw_state(false, false, BONGOCAT_FRAME_SLEEPING) == + BONGOCAT_FRAME_SLEEPING, + "idle returns configured idle_frame"); +} + +int main(void) { + printf("=== Paw Frame Tests ===\n"); + test_combinations(); + test_custom_idle_frame(); + printf("\nResults: %d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed > 0 ? 1 : 0; +} From be3a110fe03c467fdae04bc51f3b396d4fa07b9e Mon Sep 17 00:00:00 2001 From: Aestylis Date: Sat, 20 Jun 2026 21:54:56 -0700 Subject: [PATCH 3/6] feat(input): publish keycodes to the shared ring Co-Authored-By: Claude Opus 4.8 --- include/platform/input.h | 4 ++++ src/platform/input.c | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/include/platform/input.h b/include/platform/input.h index 414d78b4..00c679b6 100644 --- a/include/platform/input.h +++ b/include/platform/input.h @@ -2,6 +2,7 @@ #define INPUT_H #include "core/bongocat.h" +#include "platform/key_ring.h" #include "utils/error.h" #include @@ -16,6 +17,9 @@ extern atomic_int *any_key_pressed; // Last pressed key code for hand mapping (0 = none) extern atomic_int *last_key_code; +// Shared keycode ring: input child (producer) -> animation threads (consumers) +extern key_ring_t *key_ring; + // ============================================================================= // INPUT MONITORING FUNCTIONS // ============================================================================= diff --git a/src/platform/input.c b/src/platform/input.c index fe3cf149..6a10c1a5 100644 --- a/src/platform/input.c +++ b/src/platform/input.c @@ -25,6 +25,7 @@ atomic_int *any_key_pressed; atomic_int *last_key_code; +key_ring_t *key_ring = NULL; static pid_t input_child_pid = -1; static int wake_fd = -1; @@ -49,6 +50,15 @@ static atomic_int *alloc_shared_atomic(void) { return ptr; } +static key_ring_t *alloc_shared_key_ring(void) { + key_ring_t *ptr = mmap(NULL, sizeof(key_ring_t), PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_ANONYMOUS, -1, 0); + if (ptr == MAP_FAILED) + return NULL; + key_ring_init(ptr); + return ptr; +} + pid_t input_get_child_pid(void) { return input_child_pid; } @@ -314,6 +324,9 @@ static void capture_input_hotplug(char **static_paths, int num_static, if (ev[k].type == EV_KEY && ev[k].value == 1) { key_pressed = true; code = ev[k].code; + if (key_ring) { + key_ring_push(key_ring, code); + } if (enable_debug) { bongocat_log_debug("Key: %d from %s", code, active_devices[i].path); @@ -370,6 +383,18 @@ bongocat_error_t input_start_monitoring(char **device_paths, int num_devices, return BONGOCAT_ERROR_MEMORY; } + // Shared keycode ring (producer: this child; consumers: animation threads) + key_ring = alloc_shared_key_ring(); + if (!key_ring) { + bongocat_log_error("Failed to create shared memory for key ring: %s", + strerror(errno)); + munmap(any_key_pressed, sizeof(atomic_int)); + munmap(last_key_code, sizeof(atomic_int)); + any_key_pressed = NULL; + last_key_code = NULL; + return BONGOCAT_ERROR_MEMORY; + } + wake_fd = eventfd(0, EFD_NONBLOCK); if (wake_fd < 0) { bongocat_log_warning( @@ -383,8 +408,10 @@ bongocat_error_t input_start_monitoring(char **device_paths, int num_devices, strerror(errno)); munmap(any_key_pressed, sizeof(atomic_int)); munmap(last_key_code, sizeof(atomic_int)); + munmap(key_ring, sizeof(key_ring_t)); any_key_pressed = NULL; last_key_code = NULL; + key_ring = NULL; if (wake_fd >= 0) { close(wake_fd); wake_fd = -1; @@ -446,6 +473,16 @@ bongocat_error_t input_restart_monitoring(char **device_paths, int num_devices, } } + bool need_new_ring = (key_ring == NULL || key_ring == MAP_FAILED); + if (need_new_ring) { + key_ring = alloc_shared_key_ring(); + if (!key_ring) { + bongocat_log_error("Failed to create shared memory for key ring: %s", + strerror(errno)); + return BONGOCAT_ERROR_MEMORY; + } + } + // Recreate eventfd for the new child process if (wake_fd >= 0) { close(wake_fd); From f242a659897dcd3eaf7ac1b45d725a7cea4f3fc5 Mon Sep 17 00:00:00 2001 From: Aestylis Date: Sat, 20 Jun 2026 22:00:04 -0700 Subject: [PATCH 4/6] fix(animation): animate both paws concurrently via per-paw deadlines Co-Authored-By: Claude Opus 4.8 --- src/graphics/animation.c | 120 ++++++++++++++++++++------------------- 1 file changed, 63 insertions(+), 57 deletions(-) diff --git a/src/graphics/animation.c b/src/graphics/animation.c index 310c4b43..f50e1189 100644 --- a/src/graphics/animation.c +++ b/src/graphics/animation.c @@ -4,7 +4,9 @@ #include "graphics/animation.h" #include "graphics/embedded_assets.h" +#include "graphics/paw_frame.h" #include "platform/input.h" +#include "platform/key_ring.h" #include "platform/wayland.h" #include "utils/memory.h" @@ -49,7 +51,9 @@ static bool animation_initialized = false; // ============================================================================= typedef struct { - long hold_until; + long left_hold_until; // paw down while now_us < left_hold_until + long right_hold_until; + unsigned key_ring_tail; // process-local consumer cursor into key_ring int test_counter; int test_interval_frames; long frame_time_ns; @@ -114,29 +118,29 @@ static int get_frame_for_keycode(int keycode) { return 2; // Right hand (default for all other keys) } -static int anim_get_active_frame(void) { +// Map a keycode to a paw frame constant, honoring hand mapping + mirror. +static int anim_classify_paw(int keycode) { + int frame; if (current_config && current_config->enable_hand_mapping) { - int keycode = atomic_load(last_key_code); - int frame = get_frame_for_keycode(keycode); - // Flip hands when cat is mirrored horizontally + frame = get_frame_for_keycode(keycode); // 1 = left, 2 = right if (current_config->mirror_x) { frame = (frame == 1) ? 2 : 1; } - return frame; + } else { + frame = (rand() % 2) + 1; // random hand } - return (rand() % 2) + 1; // Random: frame 1 or 2 + return (frame == 1) ? BONGOCAT_FRAME_LEFT_DOWN : BONGOCAT_FRAME_RIGHT_DOWN; } -static void anim_trigger_frame_change(int new_frame, long duration_us, - long current_time_us, - animation_state_t *state) { - if (current_config->enable_debug) { - bongocat_log_debug("Animation frame change: %d (duration: %ld us)", - new_frame, duration_us); +// Extend the given paw's deadline to now + keypress_duration. +static void anim_press_paw(animation_state_t *state, int paw_frame, + long current_time_us) { + long duration_us = current_config->keypress_duration * 1000; + if (paw_frame == BONGOCAT_FRAME_LEFT_DOWN) { + state->left_hold_until = current_time_us + duration_us; + } else { + state->right_hold_until = current_time_us + duration_us; } - - anim_index = new_frame; - state->hold_until = current_time_us + duration_us; } static void anim_handle_test_animation(animation_state_t *state, @@ -147,51 +151,49 @@ static void anim_handle_test_animation(animation_state_t *state, state->test_counter++; if (state->test_counter > state->test_interval_frames) { - int new_frame = anim_get_active_frame(); - long duration_us = current_config->test_animation_duration * 1000; - bongocat_log_debug("Test animation trigger"); - anim_trigger_frame_change(new_frame, duration_us, current_time_us, state); + anim_press_paw(state, anim_classify_paw(rand()), current_time_us); state->test_counter = 0; } } -static void anim_handle_key_press(animation_state_t *state, - long current_time_us) { - if (!atomic_load(any_key_pressed)) { +// Drain all pending keycodes and extend per-paw deadlines. +static void anim_drain_keys(animation_state_t *state, long current_time_us) { + // During a scheduled-sleep window, ignore key intake (matches prior guard). + if (current_config->enable_scheduled_sleep && + anim_is_sleep_time(current_config)) { + return; + } + if (!key_ring) { return; } - if (!current_config->enable_scheduled_sleep || - !anim_is_sleep_time(current_config)) { - int new_frame = anim_get_active_frame(); - long duration_us = current_config->keypress_duration * 1000; - - bongocat_log_debug("Key press detected - switching to frame %d", new_frame); - anim_trigger_frame_change(new_frame, duration_us, current_time_us, state); + int codes[KEY_RING_SIZE]; + unsigned n = + key_ring_drain(key_ring, &state->key_ring_tail, codes, KEY_RING_SIZE); + if (n == 0) { + return; + } - atomic_store(any_key_pressed, 0); - state->test_counter = 0; // Reset test counter - state->last_key_pressed_timestamp = current_time_us; + for (unsigned i = 0; i < n; i++) { + anim_press_paw(state, anim_classify_paw(codes[i]), current_time_us); } + state->last_key_pressed_timestamp = current_time_us; + state->test_counter = 0; } -static void anim_handle_idle_return(animation_state_t *state, - long current_time_us) { +// Derive anim_index from sleep state, then per-paw deadlines. +static void anim_select_frame(animation_state_t *state, long current_time_us) { int show_sleep_frame = 0; - // Sleep Mode - if (current_config->enable_scheduled_sleep) { - if (anim_is_sleep_time(current_config)) { - show_sleep_frame = 1; - } + if (current_config->enable_scheduled_sleep && + anim_is_sleep_time(current_config)) { + show_sleep_frame = 1; } - // Idle Sleep if (current_config->idle_sleep_timeout_sec > 0 && - state->last_key_pressed_timestamp > 0) { - if (anim_get_current_time_us() - state->last_key_pressed_timestamp >= - current_config->idle_sleep_timeout_sec * 1000000L) { - show_sleep_frame = 1; - } + state->last_key_pressed_timestamp > 0 && + anim_get_current_time_us() - state->last_key_pressed_timestamp >= + current_config->idle_sleep_timeout_sec * 1000000L) { + show_sleep_frame = 1; } if (show_sleep_frame) { @@ -202,15 +204,15 @@ static void anim_handle_idle_return(animation_state_t *state, return; } - if (current_time_us <= state->hold_until) { - return; - } - - if (anim_index != current_config->idle_frame) { - bongocat_log_debug("Returning to idle frame %d", - current_config->idle_frame); - anim_index = current_config->idle_frame; + bool left_live = current_time_us < state->left_hold_until; + bool right_live = current_time_us < state->right_hold_until; + int new_frame = + frame_from_paw_state(left_live, right_live, current_config->idle_frame); + if (new_frame != anim_index && current_config->enable_debug) { + bongocat_log_debug("Frame -> %d (left=%d right=%d)", new_frame, + (int)left_live, (int)right_live); } + anim_index = new_frame; } static void anim_update_state(animation_state_t *state) { @@ -219,8 +221,8 @@ static void anim_update_state(animation_state_t *state) { pthread_mutex_lock(&anim_lock); anim_handle_test_animation(state, current_time_us); - anim_handle_key_press(state, current_time_us); - anim_handle_idle_return(state, current_time_us); + anim_drain_keys(state, current_time_us); + anim_select_frame(state, current_time_us); pthread_mutex_unlock(&anim_lock); } @@ -230,7 +232,11 @@ static void anim_update_state(animation_state_t *state) { // ============================================================================= static void anim_init_state(animation_state_t *state) { - state->hold_until = 0; + state->left_hold_until = 0; + state->right_hold_until = 0; + state->key_ring_tail = + key_ring ? atomic_load_explicit(&key_ring->head, memory_order_acquire) + : 0; // start at current head; ignore prior codes state->test_counter = 0; state->test_interval_frames = current_config->test_animation_interval * current_config->fps; From 2bf9b2a02400f3dc1c80f48b052b0e8c9489405b Mon Sep 17 00:00:00 2001 From: Aestylis Date: Sat, 20 Jun 2026 22:15:02 -0700 Subject: [PATCH 5/6] refactor(input): retire last_key_code/any_key_pressed for the ring The keycode ring + eventfd now cover code delivery and wake-up, so the single shared last_key_code, the any_key_pressed flag, and animation_trigger are removed. This also eliminates the multi-monitor reset race where each monitor process cleared the shared any_key_pressed flag. Co-Authored-By: Claude Opus 4.8 --- include/graphics/animation.h | 3 -- include/platform/input.h | 6 --- src/graphics/animation.c | 8 +--- src/platform/input.c | 88 +++--------------------------------- 4 files changed, 8 insertions(+), 97 deletions(-) diff --git a/include/graphics/animation.h b/include/graphics/animation.h index 412db98d..06a91eb2 100644 --- a/include/graphics/animation.h +++ b/include/graphics/animation.h @@ -42,9 +42,6 @@ BONGOCAT_NODISCARD bongocat_error_t animation_start(void); // Cleanup animation resources void animation_cleanup(void); -// Trigger key press animation -void animation_trigger(void); - // ============================================================================= // RENDERING UTILITIES // ============================================================================= diff --git a/include/platform/input.h b/include/platform/input.h index 00c679b6..d5c0c902 100644 --- a/include/platform/input.h +++ b/include/platform/input.h @@ -11,12 +11,6 @@ // INPUT STATE // ============================================================================= -// Shared memory for key press state (thread-safe) -extern atomic_int *any_key_pressed; - -// Last pressed key code for hand mapping (0 = none) -extern atomic_int *last_key_code; - // Shared keycode ring: input child (producer) -> animation threads (consumers) extern key_ring_t *key_ring; diff --git a/src/graphics/animation.c b/src/graphics/animation.c index f50e1189..2499f5b8 100644 --- a/src/graphics/animation.c +++ b/src/graphics/animation.c @@ -51,7 +51,7 @@ static bool animation_initialized = false; // ============================================================================= typedef struct { - long left_hold_until; // paw down while now_us < left_hold_until + long left_hold_until; // paw down while now_us < left_hold_until long right_hold_until; unsigned key_ring_tail; // process-local consumer cursor into key_ring int test_counter; @@ -578,9 +578,3 @@ void animation_cleanup(void) { bongocat_log_debug("Animation cleanup complete"); } - -void animation_trigger(void) { - if (any_key_pressed) { - atomic_store(any_key_pressed, 1); - } -} diff --git a/src/platform/input.c b/src/platform/input.c index 6a10c1a5..67edab7a 100644 --- a/src/platform/input.c +++ b/src/platform/input.c @@ -23,8 +23,6 @@ #include #include -atomic_int *any_key_pressed; -atomic_int *last_key_code; key_ring_t *key_ring = NULL; static pid_t input_child_pid = -1; static int wake_fd = -1; @@ -41,15 +39,6 @@ static void wait_child_exit(pid_t pid, int max_attempts) { waitpid(pid, &status, 0); } -static atomic_int *alloc_shared_atomic(void) { - atomic_int *ptr = mmap(NULL, sizeof(atomic_int), PROT_READ | PROT_WRITE, - MAP_SHARED | MAP_ANONYMOUS, -1, 0); - if (ptr == MAP_FAILED) - return NULL; - atomic_store(ptr, 0); - return ptr; -} - static key_ring_t *alloc_shared_key_ring(void) { key_ring_t *ptr = mmap(NULL, sizeof(key_ring_t), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); @@ -335,8 +324,6 @@ static void capture_input_hotplug(char **static_paths, int num_static, } if (key_pressed) { - atomic_store(last_key_code, code); - animation_trigger(); if (wake_fd >= 0) { uint64_t val = 1; if (write(wake_fd, &val, sizeof(val)) < 0) { @@ -366,32 +353,11 @@ bongocat_error_t input_start_monitoring(char **device_paths, int num_devices, int scan_interval, int enable_debug) { bongocat_log_info("Initializing input hotplug system"); - // Initialize shared memory for key press state - any_key_pressed = alloc_shared_atomic(); - if (!any_key_pressed) { - bongocat_log_error("Failed to create shared memory for input: %s", - strerror(errno)); - return BONGOCAT_ERROR_MEMORY; - } - - // Shared memory for last key code (hand mapping) - last_key_code = alloc_shared_atomic(); - if (!last_key_code) { - bongocat_log_error("Failed to create shared memory for key code: %s", - strerror(errno)); - munmap(any_key_pressed, sizeof(atomic_int)); - return BONGOCAT_ERROR_MEMORY; - } - // Shared keycode ring (producer: this child; consumers: animation threads) key_ring = alloc_shared_key_ring(); if (!key_ring) { bongocat_log_error("Failed to create shared memory for key ring: %s", strerror(errno)); - munmap(any_key_pressed, sizeof(atomic_int)); - munmap(last_key_code, sizeof(atomic_int)); - any_key_pressed = NULL; - last_key_code = NULL; return BONGOCAT_ERROR_MEMORY; } @@ -406,11 +372,7 @@ bongocat_error_t input_start_monitoring(char **device_paths, int num_devices, if (input_child_pid < 0) { bongocat_log_error("Failed to fork input monitoring process: %s", strerror(errno)); - munmap(any_key_pressed, sizeof(atomic_int)); - munmap(last_key_code, sizeof(atomic_int)); munmap(key_ring, sizeof(key_ring_t)); - any_key_pressed = NULL; - last_key_code = NULL; key_ring = NULL; if (wake_fd >= 0) { close(wake_fd); @@ -444,35 +406,7 @@ bongocat_error_t input_restart_monitoring(char **device_paths, int num_devices, input_child_pid = -1; } - // Reuse shared memory if it exists, otherwise allocate new - bool need_new_shm = - (any_key_pressed == NULL || any_key_pressed == MAP_FAILED); - - if (need_new_shm) { - any_key_pressed = alloc_shared_atomic(); - if (!any_key_pressed) { - bongocat_log_error("Failed to create shared memory for input: %s", - strerror(errno)); - return BONGOCAT_ERROR_MEMORY; - } - } - - bool need_new_key_shm = - (last_key_code == NULL || last_key_code == MAP_FAILED); - - if (need_new_key_shm) { - last_key_code = alloc_shared_atomic(); - if (!last_key_code) { - bongocat_log_error("Failed to create shared memory for key code: %s", - strerror(errno)); - if (need_new_shm) { - munmap(any_key_pressed, sizeof(atomic_int)); - any_key_pressed = NULL; - } - return BONGOCAT_ERROR_MEMORY; - } - } - + // Reuse the shared ring if it exists, otherwise allocate new bool need_new_ring = (key_ring == NULL || key_ring == MAP_FAILED); if (need_new_ring) { key_ring = alloc_shared_key_ring(); @@ -497,13 +431,9 @@ bongocat_error_t input_restart_monitoring(char **device_paths, int num_devices, if (input_child_pid < 0) { bongocat_log_error("Failed to fork input monitoring process: %s", strerror(errno)); - if (need_new_shm) { - munmap(any_key_pressed, sizeof(atomic_int)); - any_key_pressed = NULL; - } - if (need_new_key_shm) { - munmap(last_key_code, sizeof(atomic_int)); - last_key_code = NULL; + if (need_new_ring) { + munmap(key_ring, sizeof(key_ring_t)); + key_ring = NULL; } return BONGOCAT_ERROR_THREAD; } @@ -537,13 +467,9 @@ void input_cleanup(void) { } // Cleanup shared memory - if (any_key_pressed && any_key_pressed != MAP_FAILED) { - munmap(any_key_pressed, sizeof(atomic_int)); - any_key_pressed = NULL; - } - if (last_key_code && last_key_code != MAP_FAILED) { - munmap(last_key_code, sizeof(atomic_int)); - last_key_code = NULL; + if (key_ring && key_ring != MAP_FAILED) { + munmap(key_ring, sizeof(key_ring_t)); + key_ring = NULL; } bongocat_log_debug("Input monitoring cleanup complete"); From e32f5d4bbf7ad09e79187e43549a27cdbe505db9 Mon Sep 17 00:00:00 2001 From: Aestylis Date: Sat, 20 Jun 2026 22:16:33 -0700 Subject: [PATCH 6/6] docs: document concurrent paw animation + ring buffer IPC Co-Authored-By: Claude Opus 4.8 --- ARCHITECTURE.md | 13 ++++++------- CHANGELOG.md | 10 ++++++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b3165c55..b631422d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -60,7 +60,7 @@ Each instance runs 3 threads + 1 child process: | **Main thread** | Wayland event loop | `poll()` on `wl_display` fd, dispatches protocol events, handles config reload ticks | | **Animation thread** | pthread | Runs frame state machine, calls `draw_bar()` when frame changes, sleeps via `eventfd` when idle | | **Config watcher** | pthread | `inotify` on config file, debounces (300ms), triggers hot-reload | -| **Input child** | fork | Reads `/dev/input/eventX` via `poll()`, writes atomic key state + eventfd wake signal | +| **Input child** | fork | Reads `/dev/input/eventX` via `poll()`, pushes keycodes to a shared ring + eventfd wake signal | ## Data Flow @@ -71,16 +71,16 @@ Each instance runs 3 threads + 1 child process: Input Child Process (poll on evdev fds) | - | atomic_store(any_key_pressed, 1) - | atomic_store(last_key_code, code) - | write(eventfd) -- wake animation thread + | key_ring_push(key_ring, code) -- per key, lock-free + | write(eventfd) -- wake animation thread | v Animation Thread (poll on eventfd, nanosleep at FPS rate) | | anim_update_state() under anim_lock - | selects frame 0-4 based on key + hand mapping + sleep state + | drains key_ring -> per-paw deadlines -> frame 0-4 + | (both-down when both paws live; sleep takes priority) | v draw_bar() under anim_lock @@ -144,8 +144,7 @@ Version negotiation uses `MIN(advertised, desired)` to handle compositors with o |-----------|----------|-------| | `anim_lock` (pthread_mutex) | `anim_index`, `pixels`, `surface`, `buffer`, `current_config` pointer, cached frames | Animation thread + Wayland main thread | -| `atomic_int any_key_pressed` | Key press flag | Input child -> Animation thread (via `MAP_SHARED` mmap) | -| `atomic_int last_key_code` | Last keycode for hand mapping | Input child -> Animation thread (via `MAP_SHARED` mmap) | +| `key_ring_t` (atomic `head` + per-consumer local `tail`) | Keycode stream | Input child (producer) -> Animation thread(s) (consumers) via `MAP_SHARED` mmap | | `atomic_bool configured` | Surface ready flag | Wayland callbacks -> Animation thread | | `atomic_bool fullscreen_detected` | Fullscreen state | Fullscreen module -> draw_bar() | | `atomic_bool g_reload_pending` | Config change flag | Config watcher -> Main thread tick | diff --git a/CHANGELOG.md b/CHANGELOG.md index 0350c669..2c270ac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Fixed + +- **Concurrent paw animation** - Pressing keys on both sides of the keyboard now + moves both paws at once (the `both-down` frame). Previously only the + last-pressed key's paw animated. Keycodes now flow through a lock-free ring + buffer to per-paw timers, which also fixes a multi-monitor keypress race on the + old single shared flag. Fixes #78. + ## [2.0.0] - 2026-04-05 ### Breaking Changes