@camstack/shm-ring 1.0.16 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,22 @@
1
1
  import { DecodedFrame, FrameHandle, IScopedLogger } from '@camstack/types';
2
+ /**
3
+ * Idle grace before a cached reader whose segment stopped appearing in handles
4
+ * is `munmap`'d and evicted. Longer than any legitimate quiet gap between
5
+ * frames on a live segment (a stalled camera, a sparse occupancy-recheck poll,
6
+ * a keyframe wait) so a still-live segment is not churned; short enough that a
7
+ * rotated-out / restarted-decoder segment's mapping is reclaimed promptly
8
+ * instead of leaking until subscription teardown. Eviction is functionally
9
+ * safe (a re-appearing shmId re-opens the segment), so this only trades a rare
10
+ * remap syscall against how fast `/dev/shm` is reclaimed under rotation churn.
11
+ */
12
+ export declare const STALE_READER_TTL_MS = 15000;
13
+ /** Injectable knobs for the cache — all optional, all defaulted. */
14
+ export interface FrameRingReaderCacheOptions {
15
+ /** Wall clock, injectable for deterministic tests. Defaults to `Date.now`. */
16
+ readonly now?: () => number;
17
+ /** Override the idle eviction grace. Defaults to {@link STALE_READER_TTL_MS}. */
18
+ readonly staleReaderTtlMs?: number;
19
+ }
2
20
  /**
3
21
  * Opens (and caches) a `FrameRingReader` per `shmId` and reads the pixels a
4
22
  * `FrameHandle` refers to. Single-consumer — one cache per frame subscription.
@@ -6,8 +24,13 @@ import { DecodedFrame, FrameHandle, IScopedLogger } from '@camstack/types';
6
24
  export declare class FrameRingReaderCache {
7
25
  private readonly rings;
8
26
  private readonly logger;
27
+ private readonly now;
28
+ private readonly staleReaderTtlMs;
9
29
  private closed;
10
- constructor(logger?: IScopedLogger);
30
+ constructor(logger?: IScopedLogger, options?: FrameRingReaderCacheOptions);
31
+ /** Number of shm segments currently mapped by this cache. Diagnostic — also
32
+ * lets a test observe stale-reader eviction. */
33
+ get mappedSegmentCount(): number;
11
34
  /**
12
35
  * Read the pixels a `FrameHandle` refers to and return them as a
13
36
  * `DecodedFrame`. Returns `null` when the ring slot was recycled before the
@@ -18,4 +41,21 @@ export declare class FrameRingReaderCache {
18
41
  close(): void;
19
42
  /** Get the cached reader for a handle's segment, opening it on first use. */
20
43
  private ringFor;
44
+ /**
45
+ * Close (`munmap`) and evict every cached reader — except `keepShmId`, the
46
+ * one being looked up right now — whose segment stopped appearing in handles
47
+ * for longer than `staleReaderTtlMs`. Cheap: a no-op unless more than one
48
+ * segment is mapped (steady state holds exactly one per subscription), so the
49
+ * common per-read path does not even iterate.
50
+ */
51
+ private sweepStaleRings;
52
+ /**
53
+ * Unmap one cached segment and drop it from the map. The native `close()` is
54
+ * itself idempotent (see `native.ts`), and deleting from the map before the
55
+ * `munmap` keeps eviction idempotent even if `close()` throws — the reader is
56
+ * never revived. This only `munmap`s the reader's own view; it does NOT
57
+ * `shm_unlink` (the decoder that created the segment owns the name), which is
58
+ * exactly what lets the OS reclaim the backing memory once every view closes.
59
+ */
60
+ private closeRing;
21
61
  }
package/dist/index.js CHANGED
@@ -570,8 +570,6 @@ var FrameRingReader = class extends FrameRingBase {
570
570
  };
571
571
  }
572
572
  };
573
- //#endregion
574
- //#region src/frame-ring-reader-cache.ts
575
573
  /**
576
574
  * Opens (and caches) a `FrameRingReader` per `shmId` and reads the pixels a
577
575
  * `FrameHandle` refers to. Single-consumer — one cache per frame subscription.
@@ -579,9 +577,18 @@ var FrameRingReader = class extends FrameRingBase {
579
577
  var FrameRingReaderCache = class {
580
578
  rings = /* @__PURE__ */ new Map();
581
579
  logger;
580
+ now;
581
+ staleReaderTtlMs;
582
582
  closed = false;
583
- constructor(logger) {
583
+ constructor(logger, options) {
584
584
  this.logger = logger;
585
+ this.now = options?.now ?? Date.now;
586
+ this.staleReaderTtlMs = options?.staleReaderTtlMs ?? 15e3;
587
+ }
588
+ /** Number of shm segments currently mapped by this cache. Diagnostic — also
589
+ * lets a test observe stale-reader eviction. */
590
+ get mappedSegmentCount() {
591
+ return this.rings.size;
585
592
  }
586
593
  /**
587
594
  * Read the pixels a `FrameHandle` refers to and return them as a
@@ -616,28 +623,28 @@ var FrameRingReaderCache = class {
616
623
  close() {
617
624
  if (this.closed) return;
618
625
  this.closed = true;
619
- for (const [shmId, ring] of this.rings) try {
620
- ring.segment.close();
621
- } catch (err) {
622
- this.logger?.warn("frame-ring reader: segment close failed", { meta: {
623
- shmId,
624
- error: (0, _camstack_types.errMsg)(err)
625
- } });
626
- }
626
+ for (const [shmId, ring] of this.rings) this.closeRing(shmId, ring);
627
627
  this.rings.clear();
628
628
  }
629
629
  /** Get the cached reader for a handle's segment, opening it on first use. */
630
630
  ringFor(handle) {
631
+ const now = this.now();
631
632
  const cached = this.rings.get(handle.shmId);
632
- if (cached) return cached;
633
+ if (cached) {
634
+ cached.lastUsedAt = now;
635
+ this.sweepStaleRings(now, handle.shmId);
636
+ return cached;
637
+ }
633
638
  const slotByteLength = computeSlotByteLength(handle.width, handle.height, handle.format);
634
639
  try {
635
640
  const segment = openSegment(handle.shmId, computeSegmentSize(handle.slotCount, slotByteLength));
636
641
  const ring = {
637
642
  segment,
638
- reader: new FrameRingReader(segment.buffer, handle.shmId, handle.slotCount, slotByteLength, handle.nodeId)
643
+ reader: new FrameRingReader(segment.buffer, handle.shmId, handle.slotCount, slotByteLength, handle.nodeId),
644
+ lastUsedAt: now
639
645
  };
640
646
  this.rings.set(handle.shmId, ring);
647
+ this.sweepStaleRings(now, handle.shmId);
641
648
  return ring;
642
649
  } catch (err) {
643
650
  this.logger?.warn("frame-ring reader: openSegment failed", { meta: {
@@ -647,6 +654,44 @@ var FrameRingReaderCache = class {
647
654
  return null;
648
655
  }
649
656
  }
657
+ /**
658
+ * Close (`munmap`) and evict every cached reader — except `keepShmId`, the
659
+ * one being looked up right now — whose segment stopped appearing in handles
660
+ * for longer than `staleReaderTtlMs`. Cheap: a no-op unless more than one
661
+ * segment is mapped (steady state holds exactly one per subscription), so the
662
+ * common per-read path does not even iterate.
663
+ */
664
+ sweepStaleRings(now, keepShmId) {
665
+ if (this.rings.size <= 1) return;
666
+ for (const [shmId, ring] of this.rings) {
667
+ if (shmId === keepShmId) continue;
668
+ if (now - ring.lastUsedAt <= this.staleReaderTtlMs) continue;
669
+ this.logger?.debug("frame-ring reader: evicting stale mapping", { meta: {
670
+ shmId,
671
+ idleMs: now - ring.lastUsedAt
672
+ } });
673
+ this.closeRing(shmId, ring);
674
+ }
675
+ }
676
+ /**
677
+ * Unmap one cached segment and drop it from the map. The native `close()` is
678
+ * itself idempotent (see `native.ts`), and deleting from the map before the
679
+ * `munmap` keeps eviction idempotent even if `close()` throws — the reader is
680
+ * never revived. This only `munmap`s the reader's own view; it does NOT
681
+ * `shm_unlink` (the decoder that created the segment owns the name), which is
682
+ * exactly what lets the OS reclaim the backing memory once every view closes.
683
+ */
684
+ closeRing(shmId, ring) {
685
+ this.rings.delete(shmId);
686
+ try {
687
+ ring.segment.close();
688
+ } catch (err) {
689
+ this.logger?.warn("frame-ring reader: segment close failed", { meta: {
690
+ shmId,
691
+ error: (0, _camstack_types.errMsg)(err)
692
+ } });
693
+ }
694
+ }
650
695
  };
651
696
  //#endregion
652
697
  exports.FrameRingReader = FrameRingReader;
package/dist/index.mjs CHANGED
@@ -569,8 +569,6 @@ var FrameRingReader = class extends FrameRingBase {
569
569
  };
570
570
  }
571
571
  };
572
- //#endregion
573
- //#region src/frame-ring-reader-cache.ts
574
572
  /**
575
573
  * Opens (and caches) a `FrameRingReader` per `shmId` and reads the pixels a
576
574
  * `FrameHandle` refers to. Single-consumer — one cache per frame subscription.
@@ -578,9 +576,18 @@ var FrameRingReader = class extends FrameRingBase {
578
576
  var FrameRingReaderCache = class {
579
577
  rings = /* @__PURE__ */ new Map();
580
578
  logger;
579
+ now;
580
+ staleReaderTtlMs;
581
581
  closed = false;
582
- constructor(logger) {
582
+ constructor(logger, options) {
583
583
  this.logger = logger;
584
+ this.now = options?.now ?? Date.now;
585
+ this.staleReaderTtlMs = options?.staleReaderTtlMs ?? 15e3;
586
+ }
587
+ /** Number of shm segments currently mapped by this cache. Diagnostic — also
588
+ * lets a test observe stale-reader eviction. */
589
+ get mappedSegmentCount() {
590
+ return this.rings.size;
584
591
  }
585
592
  /**
586
593
  * Read the pixels a `FrameHandle` refers to and return them as a
@@ -615,28 +622,28 @@ var FrameRingReaderCache = class {
615
622
  close() {
616
623
  if (this.closed) return;
617
624
  this.closed = true;
618
- for (const [shmId, ring] of this.rings) try {
619
- ring.segment.close();
620
- } catch (err) {
621
- this.logger?.warn("frame-ring reader: segment close failed", { meta: {
622
- shmId,
623
- error: errMsg(err)
624
- } });
625
- }
625
+ for (const [shmId, ring] of this.rings) this.closeRing(shmId, ring);
626
626
  this.rings.clear();
627
627
  }
628
628
  /** Get the cached reader for a handle's segment, opening it on first use. */
629
629
  ringFor(handle) {
630
+ const now = this.now();
630
631
  const cached = this.rings.get(handle.shmId);
631
- if (cached) return cached;
632
+ if (cached) {
633
+ cached.lastUsedAt = now;
634
+ this.sweepStaleRings(now, handle.shmId);
635
+ return cached;
636
+ }
632
637
  const slotByteLength = computeSlotByteLength(handle.width, handle.height, handle.format);
633
638
  try {
634
639
  const segment = openSegment(handle.shmId, computeSegmentSize(handle.slotCount, slotByteLength));
635
640
  const ring = {
636
641
  segment,
637
- reader: new FrameRingReader(segment.buffer, handle.shmId, handle.slotCount, slotByteLength, handle.nodeId)
642
+ reader: new FrameRingReader(segment.buffer, handle.shmId, handle.slotCount, slotByteLength, handle.nodeId),
643
+ lastUsedAt: now
638
644
  };
639
645
  this.rings.set(handle.shmId, ring);
646
+ this.sweepStaleRings(now, handle.shmId);
640
647
  return ring;
641
648
  } catch (err) {
642
649
  this.logger?.warn("frame-ring reader: openSegment failed", { meta: {
@@ -646,6 +653,44 @@ var FrameRingReaderCache = class {
646
653
  return null;
647
654
  }
648
655
  }
656
+ /**
657
+ * Close (`munmap`) and evict every cached reader — except `keepShmId`, the
658
+ * one being looked up right now — whose segment stopped appearing in handles
659
+ * for longer than `staleReaderTtlMs`. Cheap: a no-op unless more than one
660
+ * segment is mapped (steady state holds exactly one per subscription), so the
661
+ * common per-read path does not even iterate.
662
+ */
663
+ sweepStaleRings(now, keepShmId) {
664
+ if (this.rings.size <= 1) return;
665
+ for (const [shmId, ring] of this.rings) {
666
+ if (shmId === keepShmId) continue;
667
+ if (now - ring.lastUsedAt <= this.staleReaderTtlMs) continue;
668
+ this.logger?.debug("frame-ring reader: evicting stale mapping", { meta: {
669
+ shmId,
670
+ idleMs: now - ring.lastUsedAt
671
+ } });
672
+ this.closeRing(shmId, ring);
673
+ }
674
+ }
675
+ /**
676
+ * Unmap one cached segment and drop it from the map. The native `close()` is
677
+ * itself idempotent (see `native.ts`), and deleting from the map before the
678
+ * `munmap` keeps eviction idempotent even if `close()` throws — the reader is
679
+ * never revived. This only `munmap`s the reader's own view; it does NOT
680
+ * `shm_unlink` (the decoder that created the segment owns the name), which is
681
+ * exactly what lets the OS reclaim the backing memory once every view closes.
682
+ */
683
+ closeRing(shmId, ring) {
684
+ this.rings.delete(shmId);
685
+ try {
686
+ ring.segment.close();
687
+ } catch (err) {
688
+ this.logger?.warn("frame-ring reader: segment close failed", { meta: {
689
+ shmId,
690
+ error: errMsg(err)
691
+ } });
692
+ }
693
+ }
649
694
  };
650
695
  //#endregion
651
696
  export { FrameRingReader, FrameRingReaderCache, FrameRingWriter, MAX_RING_SLOTS, MIN_RING_SLOTS, bytesPerPixel, computeSegmentSize, computeSlotByteLength, createSegment, deriveSlotCount, openSegment, unlinkSegment };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/shm-ring",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "description": "CamStack shared-memory frame ring — cross-platform N-API segment mapping + seqlock ring",
5
5
  "keywords": [
6
6
  "camstack",