• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dangernoodle-io / breadboard / 28732905345

05 Jul 2026 07:08AM UTC coverage: 99.782% (-0.2%) from 100.0%
28732905345

Pull #722

github

web-flow
Merge 027ac35fb into 68a3b3410
Pull Request #722: feat: add bb_cache_delete + runtime eviction (B1-592 A1)

2587 of 2592 branches covered (99.81%)

Branch coverage included in aggregate %.

105 of 113 new or added lines in 1 file covered. (92.92%)

4283 of 4293 relevant lines covered (99.77%)

10568.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.08
/platform/espidf/bb_cache/bb_cache_espidf.c
1
// Must come before any system header on Linux — glibc gates PTHREAD_MUTEX_RECURSIVE
2
// on _GNU_SOURCE (or _XOPEN_SOURCE >= 500). macOS exposes it unconditionally.
3
#if !defined(_GNU_SOURCE)
4
#define _GNU_SOURCE 1
5
#endif
6

7
#include "bb_cache.h"
8
#include "bb_event.h"
9
#include "bb_json.h"
10
#include "bb_log.h"
11
#include "bb_core.h"
12
#include "bb_mem.h"
13
#include "bb_clock.h"
14

15
#include <inttypes.h>
16
#include <stdio.h>
17
#include <string.h>
18
#include <stdlib.h>
19
#include <pthread.h>
20

21
static const char *TAG = "bb_cache";
22

23
// ---------------------------------------------------------------------------
24
// Registry entry
25
// ---------------------------------------------------------------------------
26

27
typedef struct {
28
    char                 key[BB_CACHE_KEY_MAX]; // key[0] == '\0' = slot free
29
    const void         *(*snapshot)(void); // NULL = owned mode
30
    void                *owned;           // heap buffer in owned mode; NULL in getter mode
31
    size_t               size;            // sizeof owned struct (owned mode only)
32
    bool                 has_value;       // owned mode: true once bb_cache_update has
33
                                           // copied in a value at least once (guards a
34
                                           // false-negative memcmp against a zero-init buf)
35
    bool                 fallback_seeded; // owned+fallback (PR-4a-0) only: true once the
36
                                           // cold-start snapshot() seed has run. Deliberately
37
                                           // separate from has_value -- has_value stays false
38
                                           // across a seed so the first REAL write still
39
                                           // reports changed=true unconditionally (see
40
                                           // maybe_seed_fallback()).
41
    bb_cache_serialize_fn fn;
42
    bb_event_topic_t     event_topic;     // registered event topic handle (NULL if no SSE)
43
    pthread_mutex_t      lock;            // process-lifetime — see the tombstone/generation
44
                                           // invariant documented above find_entry_locked_ref().
45
                                           // NEVER pthread_mutex_destroy'd or re-pthread_mutex_init'd
46
                                           // once ensure_init() has created it.
47
    bb_cache_flags_t     flags;           // BB_CACHE_FLAG_* bitmask
48
    char                *cached_json;     // memoized serialized "data" bytes (NULL = none yet)
49
    size_t               cached_len;      // strlen of cached_json
50
    bool                 dirty;           // true = cached_json stale, re-serialize on next get
51
    // Envelope sample-time (B1-570 PR-3): owned mode is stamped in
52
    // bb_cache_update() right after the memcpy; getter mode is stamped each
53
    // time snapshot() is invoked (serialize_locked / bb_cache_get_serialized).
54
    // Producers no longer emit their own ts_ms — bb_cache owns it and wraps
55
    // every serialize point ({"ts_ms":N,"data":{...}}).
56
    int64_t              ts_ms;
57
    // Tombstone/generation guard (B1-592 firmware-review fix): bumped every
58
    // time this slot transitions free -> in-use (bb_cache_register populating
59
    // a freed or never-used slot) AND every time it transitions in-use ->
60
    // free (bb_cache_delete). A reader that captures (entry pointer,
61
    // generation) under s_reg_lock and re-validates BOTH key and generation
62
    // after acquiring e->lock is guaranteed to either observe the exact
63
    // incarnation it looked up, or detect the mismatch and bail with
64
    // BB_ERR_NOT_FOUND -- see find_entry_locked_ref()'s doc comment.
65
    uint32_t             generation;
66
} bb_cache_entry_t;
67

68
static bb_cache_entry_t s_entries[BB_CACHE_MAX_TOPICS];
69
static pthread_mutex_t  s_reg_lock = PTHREAD_MUTEX_INITIALIZER;
70
static pthread_once_t   s_init_once = PTHREAD_ONCE_INIT;
71

72
// ---------------------------------------------------------------------------
73
// Internal helpers
74
// ---------------------------------------------------------------------------
75

76
static inline bool is_plain_getter(const bb_cache_entry_t *e) { return e->snapshot && !e->owned; }
24,429✔
77

78
// Per-slot mutexes are created exactly once, here, and are NEVER destroyed or
79
// re-initialized for the life of the process -- see the tombstone/generation
80
// invariant documented on find_entry_locked_ref() below. This is what makes
81
// it safe for a reader to hold a raw entry pointer across the window where
82
// s_reg_lock is not held: pthread_mutex_lock(&e->lock) can never target a
83
// destroyed mutex, no matter how many delete/register cycles the slot has
84
// been through.
85
//
86
// pthread_once (rather than a hand-rolled double-checked-locking bool) is the
87
// idiomatic POSIX exactly-once primitive: it guarantees do_init() runs
88
// exactly once across however many threads race ensure_init(), with no
89
// separate "already initialized" branch to (mis-)test or (mis-)synchronize.
90
static void do_init(void)
1✔
91
{
92
    pthread_mutexattr_t attr;
93
    pthread_mutexattr_init(&attr);
1✔
94
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
1✔
95
    for (int i = 0; i < BB_CACHE_MAX_TOPICS; i++) {
33✔
96
        s_entries[i].key[0] = '\0';
32✔
97
        s_entries[i].generation = 0;
32✔
98
        pthread_mutex_init(&s_entries[i].lock, &attr);
32✔
99
    }
100
    pthread_mutexattr_destroy(&attr);
1✔
101
}
1✔
102

103
static void ensure_init(void)
601,526✔
104
{
105
    pthread_once(&s_init_once, do_init);
601,526✔
106
}
601,526✔
107

108
static bb_cache_entry_t *find_entry(const char *key)
601,450✔
109
{
110
    for (int i = 0; i < BB_CACHE_MAX_TOPICS; i++) {
11,927,065✔
111
        if (s_entries[i].key[0] != '\0' && strcmp(s_entries[i].key, key) == 0) {
11,573,179✔
112
            return &s_entries[i];
247,564✔
113
        }
114
    }
115
    return NULL;
353,886✔
116
}
117

118
// Runtime lookup helper: takes s_reg_lock for the scan, releases it before
119
// returning. Used only by bb_cache_exists(), which needs presence at a single
120
// point in time and never dereferences the returned pointer's contents --
121
// every OTHER caller in this file must use find_entry_locked_ref() below
122
// instead, so it can re-validate identity after taking e->lock.
123
static bb_cache_entry_t *find_entry_locked(const char *key)
13✔
124
{
125
    pthread_mutex_lock(&s_reg_lock);
13✔
126
    bb_cache_entry_t *e = find_entry(key);
13✔
127
    pthread_mutex_unlock(&s_reg_lock);
13✔
128
    return e;
13✔
129
}
130

131
// (entry, generation) pair captured under s_reg_lock by find_entry_locked_ref().
132
// entry == NULL means "not found".
133
typedef struct {
134
    bb_cache_entry_t *entry;
135
    uint32_t          generation;
136
} entry_ref_t;
137

138
// Runtime lookup helper: takes s_reg_lock for the scan, captures both the
139
// slot pointer AND its current generation counter, releases s_reg_lock, then
140
// returns. The bb_cache_entry_t slots themselves are a fixed static array
141
// (s_entries[]) and are never freed/relocated -- a returned pointer's storage
142
// stays valid for the process lifetime (see ensure_init()'s mutex-lifetime
143
// comment), so dereferencing e->lock itself is never a use-after-free.
144
//
145
// BUT entries are no longer add-only: bb_cache_delete() can free a slot's
146
// contents, bump its generation, and mark it free (key[0] = '\0') the moment
147
// this function releases s_reg_lock, and a subsequent bb_cache_register() can
148
// re-init that same slot -- for the SAME key (a fresh incarnation) or a
149
// DIFFERENT key (cross-key slot reuse) -- bumping the generation again.
150
//
151
// Tombstone/generation invariant: every caller MUST, immediately after
152
// acquiring e->lock (and before reading or writing any other field), call
153
// entry_matches_locked(e, key, ref.generation) and bail with
154
// BB_ERR_NOT_FOUND on a mismatch. This is safe because:
155
//   - bb_cache_delete() holds s_reg_lock across its ENTIRE operation
156
//     (find + teardown + generation bump + slot-free), nested with e->lock
157
//     held only for the teardown+bump sub-step.
158
//   - bb_cache_register() likewise holds s_reg_lock across its entire
159
//     operation (find-free-slot + populate + generation bump) and never
160
//     touches e->lock at all.
161
// So a reader blocked between find_entry_locked_ref()'s release of
162
// s_reg_lock and its own pthread_mutex_lock(&e->lock) can only ever observe,
163
// once it acquires e->lock, either: (a) the SAME incarnation it looked up
164
// (key and generation both match -- safe to proceed), or (b) a LATER
165
// incarnation, whether a fresh write of the same key or a completely
166
// different key (key and/or generation mismatch -- must bail). It can never
167
// observe a half-torn-down or half-initialized slot, because neither
168
// bb_cache_delete() nor bb_cache_register() ever releases s_reg_lock
169
// mid-operation.
170
//
171
// Callers must NOT already hold s_reg_lock (non-recursive) or any entry's
172
// e->lock (lock ordering: s_reg_lock is always acquired/released before an
173
// entry's own lock, never nested inside it -- bb_cache_delete is the sole
174
// exception, documented at its call site).
175
static entry_ref_t find_entry_locked_ref(const char *key)
402,898✔
176
{
177
    entry_ref_t ref = { .entry = NULL, .generation = 0 };
402,898✔
178
    pthread_mutex_lock(&s_reg_lock);
402,898✔
179
    bb_cache_entry_t *e = find_entry(key);
402,898✔
180
    if (e) {
402,898✔
181
        ref.entry      = e;
181,550✔
182
        ref.generation = e->generation;
181,550✔
183
    }
184
    pthread_mutex_unlock(&s_reg_lock);
402,898✔
185
    return ref;
402,898✔
186
}
187

188
// Re-validation predicate -- call under e->lock immediately after acquiring
189
// it, using the (key, generation) pair captured by find_entry_locked_ref().
190
// See that function's doc comment for the full invariant.
191
static inline bool entry_matches_locked(const bb_cache_entry_t *e, const char *key,
181,549✔
192
                                         uint32_t generation)
193
{
194
    return e->key[0] != '\0' && e->generation == generation && strcmp(e->key, key) == 0;
181,549!
195
}
196

197
// Tear down an entry's owned resources, mirroring bb_cache_reset_for_test's
198
// per-entry teardown EXACTLY. Caller holds e->lock. Does NOT touch e->key, e->event_topic,
199
// e->generation, or the mutex itself -- those are the caller's responsibility
200
// (bb_cache_delete bumps generation and clears key/event_topic itself, from
201
// its own scope, after this helper returns).
202
static void free_entry_locked(bb_cache_entry_t *e)
66,520✔
203
{
204
    bb_mem_free(e->owned);
66,520✔
205
    e->owned = NULL;
66,520✔
206
    if (e->cached_json) bb_json_free_str(e->cached_json);
66,520✔
207
    e->cached_json = NULL;
66,520✔
208
    e->cached_len = 0;
66,520✔
209
    e->dirty = true;
66,520✔
210
    e->has_value = false;
66,520✔
211
    e->fallback_seeded = false;
66,520✔
212
}
66,520✔
213

214
// Owned+fallback cold-start seed (PR-4a-0). Called under e->lock from every
215
// read/serialize path that is about to hand out e->owned bytes.
216
//
217
// If the entry has BOTH an owned buffer (e->owned != NULL) AND a fallback
218
// getter (e->snapshot != NULL) -- the owned+fallback tri-state -- and no real
219
// write has landed yet (e->has_value == false), invoke snapshot() ONCE and
220
// copy its bytes into the owned buffer so a reader never sees an empty/zero
221
// cold-start value. `fallback_seeded` guards against invoking snapshot()
222
// again on a subsequent read while still unpopulated.
223
//
224
// Deliberately does NOT set has_value: that flag is reserved for
225
// bb_cache_update's memcmp change-detect guard, so the entry's first REAL
226
// write still reports changed=true unconditionally (identical to plain
227
// owned mode), never comparing against these seeded bytes. The seed is a
228
// boot-race bridge, not a "change" -- it must never look like a write to the
229
// change-detection or (future PR-4b) observer-notify machinery.
230
//
231
// No-op for plain owned mode (e->snapshot == NULL). Every call site only
232
// invokes this function when e->owned != NULL (the "not a plain getter"
233
// branch) -- plain getter mode (owned == NULL) never reaches here -- so
234
// e->snapshot != NULL at this point unambiguously means owned+fallback.
235
static void maybe_seed_fallback(bb_cache_entry_t *e)
35,186✔
236
{
237
    if (!e->snapshot) return;   // plain owned mode, not owned+fallback
35,186✔
238
    if (e->has_value || e->fallback_seeded) return;  // already real or seeded
11✔
239

240
    const void *snap = e->snapshot();
9✔
241
    if (snap) {
9✔
242
        memcpy(e->owned, snap, e->size);
8✔
243
        e->ts_ms = (int64_t)bb_clock_now_ms64();  // seed IS a sample
8✔
244
    }
245
    e->fallback_seeded = true;  // never retry, even if snapshot() returned NULL
9✔
246
}
247

248
// Serialize entry contents into obj under the entry's lock.
249
// Entry must not be NULL. Caller holds no lock before calling.
250
// key/generation are the identity captured by find_entry_locked_ref(); this
251
// function re-validates them immediately after taking e->lock (tombstone/
252
// generation guard, see find_entry_locked_ref()'s doc comment) and returns
253
// BB_ERR_NOT_FOUND on a mismatch WITHOUT reading or writing any other field.
254
// out_ts_ms, when non-NULL, receives the entry's envelope sample-time: for
255
// getter-mode entries this call stamps ts_ms = now (the read IS the sample);
256
// for owned-mode entries ts_ms was already stamped by the last bb_cache_update.
257
// out_topic, when non-NULL, receives e->event_topic -- read under the same
258
// lock+validation so callers (bb_cache_post) never read event_topic unguarded.
259
static bb_err_t serialize_locked(bb_cache_entry_t *e, const char *key, uint32_t generation,
12,173✔
260
                                  bb_json_t obj, int64_t *out_ts_ms, bb_event_topic_t *out_topic)
261
{
262
    pthread_mutex_lock(&e->lock);
12,173✔
263

264
    if (!entry_matches_locked(e, key, generation)) {
12,173!
NEW
265
        pthread_mutex_unlock(&e->lock);
×
NEW
266
        return BB_ERR_NOT_FOUND;
×
267
    }
268

269
    if (out_topic) *out_topic = e->event_topic;
12,173✔
270

271
    const void *snap;
272
    if (is_plain_getter(e)) {
12,173✔
273
        // Plain getter mode: no owned buffer, always pull through.
274
        snap = e->snapshot();
3✔
275
        e->ts_ms = (int64_t)bb_clock_now_ms64();
3✔
276
    } else {
277
        // Plain owned mode, or owned+fallback (seed if still unpopulated).
278
        maybe_seed_fallback(e);
12,170✔
279
        snap = e->owned;
12,170✔
280
    }
281

282
    if (!snap) {
12,173✔
283
        pthread_mutex_unlock(&e->lock);
1✔
284
        bb_log_w(TAG, "key '%s': no snapshot available", e->key);
1✔
285
        return BB_ERR_INVALID_STATE;
1✔
286
    }
287

288
    e->fn(obj, snap);
12,172✔
289
    if (out_ts_ms) *out_ts_ms = e->ts_ms;
12,172✔
290
    pthread_mutex_unlock(&e->lock);
12,172✔
291
    return BB_OK;
12,172✔
292
}
293

294
// ---------------------------------------------------------------------------
295
// Public API
296
// ---------------------------------------------------------------------------
297

298
bb_err_t bb_cache_register(const bb_cache_config_t *cfg)
66,533✔
299
{
300
    if (!cfg || !cfg->key || !cfg->serialize) return BB_ERR_INVALID_ARG;
66,533✔
301
    if (strlen(cfg->key) >= BB_CACHE_KEY_MAX) {
66,530✔
302
        bb_log_e(TAG, "key '%s' too long (max %d chars)", cfg->key, BB_CACHE_KEY_MAX - 1);
1✔
303
        return BB_ERR_INVALID_ARG;
1✔
304
    }
305

306
    ensure_init();
66,529✔
307

308
    pthread_mutex_lock(&s_reg_lock);
66,529✔
309

310
    // Idempotent: already registered?
311
    if (find_entry(cfg->key)) {
66,529✔
312
        pthread_mutex_unlock(&s_reg_lock);
4✔
313
        return BB_OK;
4✔
314
    }
315

316
    // Find a free slot
317
    bb_cache_entry_t *slot = NULL;
66,525✔
318
    for (int i = 0; i < BB_CACHE_MAX_TOPICS; i++) {
68,839✔
319
        if (s_entries[i].key[0] == '\0') {
68,836✔
320
            slot = &s_entries[i];
66,522✔
321
            break;
66,522✔
322
        }
323
    }
324

325
    if (!slot) {
66,525✔
326
        pthread_mutex_unlock(&s_reg_lock);
3✔
327
        bb_log_e(TAG, "registry full (max %d)", BB_CACHE_MAX_TOPICS);
3✔
328
        return BB_ERR_NO_SPACE;
3✔
329
    }
330

331
    // Tri-state ownership (see bb_cache.h): snap_size > 0 allocates an owned
332
    // buffer regardless of snapshot -- covers both plain OWNED (snapshot ==
333
    // NULL, size mandatory) and OWNED+FALLBACK (snapshot != NULL, size
334
    // opts in the cold-start seed). snap_size == 0 with snapshot == NULL is
335
    // invalid (owned mode needs a size); snap_size == 0 with snapshot !=
336
    // NULL is plain GETTER mode (no owned buffer).
337
    void *owned = NULL;
66,522✔
338
    if (cfg->snap_size > 0) {
66,522✔
339
        owned = bb_calloc_prefer_spiram(1, cfg->snap_size);
66,513✔
340
        if (!owned) {
66,513✔
341
            pthread_mutex_unlock(&s_reg_lock);
1✔
342
            return BB_ERR_NO_SPACE;
1✔
343
        }
344
    } else if (!cfg->snapshot) {
9✔
345
        pthread_mutex_unlock(&s_reg_lock);
1✔
346
        return BB_ERR_INVALID_ARG;
1✔
347
    }
348

349
    // Register event topic only when SSE flag is set.
350
    // Sink-only entries (no SSE delivery) skip this — bb_cache_post returns
351
    // BB_ERR_INVALID_STATE when event_topic is NULL, guarding against misuse.
352
    bb_event_topic_t ev_topic = NULL;
66,520✔
353
    if (cfg->flags & BB_CACHE_FLAG_SSE) {
66,520✔
354
        bb_event_topic_register(cfg->key, &ev_topic);
66,412✔
355
    }
356

357
    // slot->lock is process-lifetime (created once in ensure_init(), never
358
    // destroyed) -- do NOT touch it here. Every free-slot -> in-use
359
    // transition (fresh slot or a bb_cache_delete()'d one) bumps generation
360
    // so any reader holding a pre-transition (entry, generation) pair from
361
    // find_entry_locked_ref() detects the mismatch and bails with
362
    // BB_ERR_NOT_FOUND (see that function's doc comment). No reader can be
363
    // racing this write at this point because register() itself never
364
    // touches slot->lock and holds s_reg_lock across this entire function --
365
    // see find_entry_locked_ref()'s invariant.
366
    slot->generation++;
66,520✔
367

368
    strncpy(slot->key, cfg->key, sizeof(slot->key) - 1);
66,520✔
369
    slot->key[sizeof(slot->key) - 1] = '\0';
66,520✔
370
    slot->snapshot    = cfg->snapshot;
66,520✔
371
    slot->owned       = owned;
66,520✔
372
    slot->size        = cfg->snap_size;
66,520✔
373
    slot->has_value   = false;
66,520✔
374
    slot->fallback_seeded = false;
66,520✔
375
    slot->fn          = cfg->serialize;
66,520✔
376
    slot->event_topic = ev_topic;
66,520✔
377
    slot->flags       = cfg->flags;
66,520✔
378
    slot->cached_json = NULL;
66,520✔
379
    slot->cached_len  = 0;
66,520✔
380
    slot->dirty       = true;   // no bytes cached yet
66,520✔
381
    slot->ts_ms       = 0;      // stamped on first update()/snapshot() read
66,520✔
382

383
    pthread_mutex_unlock(&s_reg_lock);
66,520✔
384
    return BB_OK;
66,520✔
385
}
386

387
bb_err_t bb_cache_update(const bb_cache_update_t *req)
90,397✔
388
{
389
    if (!req || !req->key || !req->snap) return BB_ERR_INVALID_ARG;
90,397✔
390

391
    ensure_init();
90,393✔
392

393
    entry_ref_t ref = find_entry_locked_ref(req->key);
90,393✔
394
    if (!ref.entry) return BB_ERR_NOT_FOUND;
90,393✔
395
    bb_cache_entry_t *e = ref.entry;
78,389✔
396

397
    pthread_mutex_lock(&e->lock);
78,389✔
398
    if (!entry_matches_locked(e, req->key, ref.generation)) {
78,389!
NEW
399
        pthread_mutex_unlock(&e->lock);
×
NEW
400
        return BB_ERR_NOT_FOUND;
×
401
    }
402

403
    // Plain getter-mode (no owned buffer): caller owns the struct; update is
404
    // a no-op. No owned bytes to diff against, so changed is always reported
405
    // false. Gated on e->owned (not e->snapshot) so owned+fallback entries
406
    // (which have BOTH set) fall through to the real write path below.
407
    if (!e->owned) {
78,389✔
408
        pthread_mutex_unlock(&e->lock);
2✔
409
        if (req->out_changed) *req->out_changed = false;
2✔
410
        return BB_OK;
2✔
411
    }
412

413
    // Compute change BEFORE the copy-in: first write since register is always
414
    // a change (guards the false negative where memcmp against a
415
    // zero-initialized owned buffer would otherwise report unchanged).
416
    bool changed = (!e->has_value) || (memcmp(req->snap, e->owned, e->size) != 0);
78,387✔
417
    memcpy(e->owned, req->snap, e->size);
78,387✔
418
    e->has_value = true;
78,387✔
419
    // Envelope sample-time (owned mode): default to now; req->ts_ms overrides
420
    // when the caller supplies its own sample time (e.g. ingress/self-emit
421
    // source timestamp).
422
    e->ts_ms = req->ts_ms != 0 ? req->ts_ms : (int64_t)bb_clock_now_ms64();
78,387✔
423
    e->dirty = true;   // invalidate memoized bytes; do NOT serialize here
78,387✔
424
    pthread_mutex_unlock(&e->lock);
78,387✔
425

426
    if (req->out_changed) *req->out_changed = changed;
78,387✔
427
    return BB_OK;
78,387✔
428
}
429

430
bb_err_t bb_cache_delete(const char *key)
132,011✔
431
{
432
    if (!key) return BB_ERR_INVALID_ARG;
132,011✔
433

434
    ensure_init();
132,010✔
435

436
    // Deliberate exception to the find_entry_locked_ref pattern: this function
437
    // holds s_reg_lock across the ENTIRE delete (find + teardown + generation
438
    // bump + slot free), not just the lookup. A narrower critical section
439
    // (lock, find, unlock, then lock again to tear down) would open a window
440
    // where a concurrent bb_cache_register() observes the about-to-be-deleted
441
    // slot as still "in use" (key[0] != '\0') and picks a DIFFERENT free
442
    // slot, or — worse — a concurrent delete of the same key races this one.
443
    // Holding s_reg_lock for the whole operation makes the delete atomic with
444
    // respect to every other registry-mutating call (register/delete),
445
    // matching how bb_cache_register already holds s_reg_lock across its own
446
    // find+init.
447
    pthread_mutex_lock(&s_reg_lock);
132,010✔
448

449
    bb_cache_entry_t *e = find_entry(key);
132,010✔
450
    if (!e) {
132,010✔
451
        pthread_mutex_unlock(&s_reg_lock);
66,005✔
452
        return BB_ERR_NOT_FOUND;
66,005✔
453
    }
454

455
    pthread_mutex_lock(&e->lock);
66,005✔
456
    free_entry_locked(e);
66,005✔
457
    // Tombstone: bump generation so any reader holding a pre-delete (entry,
458
    // generation) pair from find_entry_locked_ref() detects the mismatch
459
    // after acquiring e->lock and bails with BB_ERR_NOT_FOUND, instead of
460
    // observing a torn-down or (once reused) a completely different key's
461
    // data under this lock. e->lock itself is NEVER destroyed here -- see
462
    // ensure_init()'s mutex-lifetime comment -- which is what makes this
463
    // guard sufficient without a destroyed-mutex UB risk.
464
    e->generation++;
66,005✔
465
    pthread_mutex_unlock(&e->lock);
66,005✔
466

467
    // Known Phase-A limitation: e->event_topic (if any, BB_CACHE_FLAG_SSE) is
468
    // intentionally NOT torn down here -- bb_event has no topic-unregister
469
    // primitive. See the loud warning on bb_cache_delete() in bb_cache.h.
470
    e->event_topic = NULL;
66,005✔
471

472
    e->key[0] = '\0';  // last: marks the slot free for bb_cache_register reuse
66,005✔
473

474
    pthread_mutex_unlock(&s_reg_lock);
66,005✔
475
    return BB_OK;
66,005✔
476
}
477

478
bool bb_cache_exists(const char *key)
14✔
479
{
480
    if (!key) return false;
14✔
481

482
    ensure_init();
13✔
483

484
    return find_entry_locked(key) != NULL;
13✔
485
}
486

487
bb_err_t bb_cache_post(const char *key)
128✔
488
{
489
    if (!key) return BB_ERR_INVALID_ARG;
128✔
490

491
    ensure_init();
127✔
492

493
    entry_ref_t ref = find_entry_locked_ref(key);
127✔
494
    if (!ref.entry) return BB_ERR_NOT_FOUND;
127✔
495
    bb_cache_entry_t *e = ref.entry;
126✔
496

497
    // Envelope (B1-570 PR-3): {"ts_ms":N,"data":{...}}. Serialize the
498
    // producer's fields into a nested "data" object rather than round-tripping
499
    // through a string, then attach ts_ms + data to the envelope root.
500
    bb_json_t data = bb_json_obj_new();
126✔
501
    if (!data) return BB_ERR_NO_SPACE;
126✔
502

503
    // serialize_locked re-validates (key, generation) under e->lock and also
504
    // hands back e->event_topic read under that SAME lock -- bb_cache_post
505
    // must never read e->event_topic unguarded (it can change identity the
506
    // instant a concurrent delete+re-register races this call).
507
    int64_t ts_ms = 0;
125✔
508
    bb_event_topic_t topic = NULL;
125✔
509
    bb_err_t err = serialize_locked(e, key, ref.generation, data, &ts_ms, &topic);
125✔
510
    if (err != BB_OK) {
125✔
511
        bb_json_free(data);
1✔
512
        return err;
1✔
513
    }
514
    if (!topic) {
124✔
515
        bb_json_free(data);
15✔
516
        return BB_ERR_INVALID_STATE;
15✔
517
    }
518

519
    bb_json_t root = bb_json_obj_new();
109✔
520
    if (!root) {
109✔
521
        bb_json_free(data);
1✔
522
        return BB_ERR_NO_SPACE;
1✔
523
    }
524
    bb_json_obj_set_int(root, "ts_ms", ts_ms);
108✔
525
    bb_json_obj_set_obj(root, "data", data);  // ownership of data transfers to root
108✔
526

527
    char *payload = bb_json_serialize(root);
108✔
528
    bb_json_free(root);
108✔
529
    if (!payload) return BB_ERR_NO_SPACE;
108✔
530

531
    size_t len = strlen(payload);
107✔
532
    err = bb_event_post(topic, 0, payload, len + 1);
107✔
533
    bb_json_free_str(payload);
107✔
534
    return err;
107✔
535
}
536

537
bb_err_t bb_cache_serialize_into(const char *key, bb_json_t obj)
24,052✔
538
{
539
    if (!key || !obj) return BB_ERR_INVALID_ARG;
24,052✔
540

541
    ensure_init();
24,050✔
542

543
    entry_ref_t ref = find_entry_locked_ref(key);
24,050✔
544
    if (!ref.entry) return BB_ERR_NOT_FOUND;
24,050✔
545

546
    // No envelope here by design — this call embeds the key's fields directly
547
    // as a section of a larger composed document (see header comment).
548
    return serialize_locked(ref.entry, key, ref.generation, obj, NULL, NULL);
12,048✔
549
}
550

551
bb_err_t bb_cache_post_serialized(const char *key, const char *json, size_t json_len)
240,065✔
552
{
553
    if (!key || !json) return BB_ERR_INVALID_ARG;
240,065✔
554

555
    ensure_init();
240,063✔
556

557
    entry_ref_t ref = find_entry_locked_ref(key);
240,063✔
558
    if (!ref.entry) return BB_ERR_NOT_FOUND;
240,063✔
559
    bb_cache_entry_t *e = ref.entry;
66,727✔
560

561
    pthread_mutex_lock(&e->lock);
66,727✔
562
    if (!entry_matches_locked(e, key, ref.generation)) {
66,727✔
563
        pthread_mutex_unlock(&e->lock);
8,175✔
564
        return BB_ERR_NOT_FOUND;
8,175✔
565
    }
566
    // Read event_topic under the SAME lock+validation, never unguarded.
567
    bb_event_topic_t topic = e->event_topic;
58,552✔
568
    pthread_mutex_unlock(&e->lock);
58,552✔
569

570
    if (!topic) return BB_ERR_INVALID_STATE;
58,552✔
571

572
    return bb_event_post(topic, 0, json, json_len + 1);
58,490✔
573
}
574

575
bb_err_t bb_cache_get_serialized(const char *key, char *buf, size_t cap, size_t *out_len)
24,262✔
576
{
577
    if (!key || !buf || cap == 0) return BB_ERR_INVALID_ARG;
24,262✔
578

579
    ensure_init();
24,259✔
580

581
    entry_ref_t ref = find_entry_locked_ref(key);
24,259✔
582
    if (!ref.entry) return BB_ERR_NOT_FOUND;
24,259✔
583
    bb_cache_entry_t *e = ref.entry;
12,255✔
584

585
    pthread_mutex_lock(&e->lock);
12,255✔
586
    if (!entry_matches_locked(e, key, ref.generation)) {
12,255!
NEW
587
        pthread_mutex_unlock(&e->lock);
×
NEW
588
        return BB_ERR_NOT_FOUND;
×
589
    }
590

591
    // Plain getter-mode entries (no owned buffer) have no dirty signal (data
592
    // can change without an update), so always re-serialize. Owned-mode and
593
    // owned+fallback entries (both have an owned buffer) memoize via dirty.
594
    //
595
    // e->cached_json == NULL is provably unreachable whenever e->dirty is
596
    // false: the ONLY site that sets dirty = false is immediately after
597
    // cached_json = s (a non-NULL successful bb_json_serialize result) a few
598
    // lines below; every other write to dirty sets it true, and every write
599
    // to cached_json = NULL (free_entry_locked, bb_cache_register) is always
600
    // paired with dirty = true in the same scope. So "dirty false, cached_json
601
    // NULL" can never coexist -- LCOV_EXCL_BR_LINE on the middle term below.
602
    bool need = e->dirty || e->cached_json == NULL || is_plain_getter(e);  // LCOV_EXCL_BR_LINE
12,255✔
603
    if (need) {
12,255✔
604
        const void *snap;
605
        if (is_plain_getter(e)) {
11,016✔
606
            snap = e->snapshot();
3✔
607
            e->ts_ms = (int64_t)bb_clock_now_ms64();  // getter mode: read IS the sample
3✔
608
        } else {
609
            maybe_seed_fallback(e);  // owned+fallback: seed if still unpopulated
11,013✔
610
            snap = e->owned;
11,013✔
611
        }
612
        if (!snap) {
11,016✔
613
            pthread_mutex_unlock(&e->lock);
1✔
614
            bb_log_w(TAG, "key '%s': no snapshot available", e->key);
1✔
615
            return BB_ERR_INVALID_STATE;
1✔
616
        }
617

618
        bb_json_t obj = bb_json_obj_new();
11,015✔
619
        if (!obj) {
11,015✔
620
            pthread_mutex_unlock(&e->lock);
1✔
621
            return BB_ERR_NO_SPACE;
1✔
622
        }
623

624
        // The serializer runs exactly once per generation here. cached_json
625
        // holds only the inner "data" bytes -- the envelope ({"ts_ms":N,
626
        // "data":...}) is applied below, around the memoized string, on every
627
        // read (owned mode: ts_ms is frozen between updates, so the envelope
628
        // bytes stay byte-identical across reads within an interval).
629
        e->fn(obj, snap);
11,014✔
630
        char *s = bb_json_serialize(obj);
11,014✔
631
        bb_json_free(obj);
11,014✔
632
        if (!s) {
11,014✔
633
            pthread_mutex_unlock(&e->lock);
1✔
634
            return BB_ERR_NO_SPACE;
1✔
635
        }
636

637
        if (e->cached_json) bb_json_free_str(e->cached_json);
11,013✔
638
        e->cached_json = s;
11,013✔
639
        e->cached_len  = strlen(s);
11,013✔
640
        e->dirty       = false;
11,013✔
641
    }
642

643
    // Wrap the memoized "data" bytes in the envelope and copy out under the
644
    // lock. Compute the required length first (snprintf(NULL,0,...)) so an
645
    // undersized buffer is refused WITHOUT a partial write, matching the
646
    // pre-envelope contract ("buf untouched" on BB_ERR_NO_SPACE).
647
    int need_len = snprintf(NULL, 0, "{\"ts_ms\":%" PRId64 ",\"data\":%s}",
12,252✔
648
                             e->ts_ms, e->cached_json);
649
    if (need_len < 0) {  // LCOV_EXCL_BR_LINE -- snprintf only returns negative
12,252✔
650
                          // on an encoding error; unreachable with the fixed,
651
                          // well-formed format string above, and there is no
652
                          // host fault-injection seam for libc's snprintf.
653
        pthread_mutex_unlock(&e->lock);
×
654
        return BB_ERR_NO_SPACE;
×
655
    }
656
    if ((size_t)need_len + 1 > cap) {
12,252✔
657
        pthread_mutex_unlock(&e->lock);
3✔
658
        bb_log_w(TAG, "key '%s': buffer too small (need %d, cap %zu)",
3✔
659
                 key, need_len + 1, cap);
660
        return BB_ERR_NO_SPACE;
3✔
661
    }
662
    snprintf(buf, cap, "{\"ts_ms\":%" PRId64 ",\"data\":%s}", e->ts_ms, e->cached_json);
12,249✔
663
    if (out_len) *out_len = (size_t)need_len;
12,249✔
664

665
    pthread_mutex_unlock(&e->lock);
12,249✔
666
    return BB_OK;
12,249✔
667
}
668

669
// ---------------------------------------------------------------------------
670
// Keyed enumeration + compact struct-read accessor
671
// ---------------------------------------------------------------------------
672

673
size_t bb_cache_count(void)
5✔
674
{
675
    ensure_init();
5✔
676

677
    size_t count = 0;
5✔
678
    pthread_mutex_lock(&s_reg_lock);
5✔
679
    for (int i = 0; i < BB_CACHE_MAX_TOPICS; i++) {
165✔
680
        if (s_entries[i].key[0] != '\0') count++;
160✔
681
    }
682
    pthread_mutex_unlock(&s_reg_lock);
5✔
683
    return count;
5✔
684
}
685

686
bb_err_t bb_cache_key_at(size_t index, char *buf, size_t cap)
67✔
687
{
688
    if (!buf || cap == 0) return BB_ERR_INVALID_ARG;
67✔
689
    if (index >= (size_t)BB_CACHE_MAX_TOPICS) return BB_ERR_NOT_FOUND;
65✔
690

691
    ensure_init();
64✔
692

693
    // Copy the key BY VALUE under s_reg_lock -- never hand back a raw pointer
694
    // into s_entries[] (same UAF class the bb_cache_foreach fix eliminated:
695
    // a concurrent delete+re-register could rename the slot's key bytes out
696
    // from under a caller still holding a raw pointer).
697
    pthread_mutex_lock(&s_reg_lock);
64✔
698
    size_t len = strlen(s_entries[index].key);
64✔
699
    if (len + 1 > cap) {
64✔
700
        pthread_mutex_unlock(&s_reg_lock);
1✔
701
        return BB_ERR_NO_SPACE;
1✔
702
    }
703
    memcpy(buf, s_entries[index].key, len + 1);
63✔
704
    pthread_mutex_unlock(&s_reg_lock);
63✔
705
    return BB_OK;
63✔
706
}
707

708
bb_err_t bb_cache_foreach(void (*cb)(const char *key, void *ctx), void *ctx)
8✔
709
{
710
    if (!cb) return BB_ERR_INVALID_ARG;
8✔
711

712
    ensure_init();
7✔
713

714
    // Snapshot keys BY VALUE (memcpy the bytes) under the registry lock, then
715
    // release before invoking cb — avoids reentrancy deadlock if cb calls
716
    // back into bb_cache. Keys are no longer add-only (bb_cache_delete can
717
    // free and reuse a slot), so a snapshot of raw pointers into s_entries[]
718
    // would risk a concurrent delete+re-register renaming the slot's key
719
    // bytes under an in-flight cb; copying the bytes onto this function's
720
    // stack sidesteps that entirely. Stack cost:
721
    // BB_CACHE_MAX_TOPICS * BB_CACHE_KEY_MAX (default 32*96 = 3 KB).
722
    char keys[BB_CACHE_MAX_TOPICS][BB_CACHE_KEY_MAX];
723
    int n = 0;
7✔
724

725
    pthread_mutex_lock(&s_reg_lock);
7✔
726
    for (int i = 0; i < BB_CACHE_MAX_TOPICS; i++) {
231✔
727
        if (s_entries[i].key[0] != '\0') {
224✔
728
            memcpy(keys[n], s_entries[i].key, BB_CACHE_KEY_MAX);
9✔
729
            n++;
9✔
730
        }
731
    }
732
    pthread_mutex_unlock(&s_reg_lock);
7✔
733

734
    for (int i = 0; i < n; i++) {
16✔
735
        cb(keys[i], ctx);
9✔
736
    }
737
    return BB_OK;
7✔
738
}
739

740
bb_err_t bb_cache_get_raw(const char *key, void *buf, size_t cap)
24,009✔
741
{
742
    if (!key || !buf || cap == 0) return BB_ERR_INVALID_ARG;
24,009✔
743

744
    ensure_init();
24,006✔
745

746
    entry_ref_t ref = find_entry_locked_ref(key);
24,006✔
747
    if (!ref.entry) return BB_ERR_NOT_FOUND;
24,006✔
748
    bb_cache_entry_t *e = ref.entry;
12,005✔
749

750
    pthread_mutex_lock(&e->lock);
12,005✔
751
    if (!entry_matches_locked(e, key, ref.generation)) {
12,005!
NEW
752
        pthread_mutex_unlock(&e->lock);
×
NEW
753
        return BB_ERR_NOT_FOUND;
×
754
    }
755
    if (!e->owned) {
12,005✔
756
        pthread_mutex_unlock(&e->lock);
1✔
757
        return BB_ERR_INVALID_STATE;
1✔
758
    }
759
    if (cap < e->size) {
12,004✔
760
        pthread_mutex_unlock(&e->lock);
1✔
761
        bb_log_w(TAG, "get_raw '%s': buf too small (%zu < %zu)", key, cap, e->size);
1✔
762
        return BB_ERR_NO_SPACE;
1✔
763
    }
764
    maybe_seed_fallback(e);  // owned+fallback: seed if still unpopulated
12,003✔
765
    memcpy(buf, e->owned, e->size);
12,003✔
766
    pthread_mutex_unlock(&e->lock);
12,003✔
767
    return BB_OK;
12,003✔
768
}
769

770
// ---------------------------------------------------------------------------
771
// Test reset (guarded by BB_CACHE_TESTING)
772
// ---------------------------------------------------------------------------
773

774
#ifdef BB_CACHE_TESTING
775
void bb_cache_reset_for_test(void)
4,068✔
776
{
777
    pthread_mutex_lock(&s_reg_lock);
4,068✔
778
    for (int i = 0; i < BB_CACHE_MAX_TOPICS; i++) {
134,244✔
779
        if (s_entries[i].key[0] != '\0') {
130,176✔
780
            // Reuse free_entry_locked for the owned/cached_json/dirty/
781
            // has_value/fallback_seeded teardown (dedup -- was hand-rolled
782
            // here, duplicating bb_cache_delete()'s logic). Deliberately does
783
            // NOT call pthread_mutex_destroy: the mutex is process-lifetime
784
            // (see ensure_init()'s comment) and is NEVER destroyed, including
785
            // in test teardown. s_init_once (pthread_once) is likewise never
786
            // reset, so a later ensure_init() call in the SAME test binary
787
            // never attempts to re-pthread_mutex_init an already-valid mutex
788
            // (UB per POSIX).
789
            pthread_mutex_lock(&s_entries[i].lock);
515✔
790
            free_entry_locked(&s_entries[i]);
515✔
791
            pthread_mutex_unlock(&s_entries[i].lock);
515✔
792

793
            s_entries[i].key[0]      = '\0';
515✔
794
            s_entries[i].snapshot    = NULL;
515✔
795
            s_entries[i].fn          = NULL;
515✔
796
            s_entries[i].event_topic = NULL;
515✔
797
            s_entries[i].flags       = BB_CACHE_FLAG_NONE;
515✔
798
            s_entries[i].ts_ms       = 0;
515✔
799
            s_entries[i].generation  = 0;
515✔
800
        }
801
    }
802
    pthread_mutex_unlock(&s_reg_lock);
4,068✔
803
}
4,068✔
804
#endif
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc