@camstack/shm-ring 1.0.18 → 1.0.20

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.
@@ -0,0 +1,181 @@
1
+ import { FrameMeta } from './frame-ring.js';
2
+ import { IScopedLogger, FrameHandle } from '@camstack/types';
3
+ /**
4
+ * A reserved ring slot handed back by {@link DecoderFrameRingSink.beginFrame}.
5
+ *
6
+ * The slot's seqlock is open (odd) — a reader skips it. The caller fills
7
+ * `buffer` (a writable view directly over the mapped segment — the node-av
8
+ * scaler scatters its packed output straight into it, zero write-side copy)
9
+ * then publishes the frame via {@link DecoderFrameRingSink.commitFrame}.
10
+ */
11
+ export interface DecoderFrameSlot {
12
+ /** The ring slot index — pass it back to `commitFrame`. */
13
+ readonly slot: number;
14
+ /** A writable view directly over the slot's pixel region in shared memory. */
15
+ readonly buffer: Buffer;
16
+ }
17
+ /**
18
+ * Per-ring shared-memory budget (MB) — the slot count is derived per-resolution
19
+ * from this budget via {@link deriveSlotCount}, so a 360p stream gets many
20
+ * slots and a 4K stream a few, both inside the same memory footprint.
21
+ *
22
+ * Read ONCE at module load from `CAMSTACK_SHM_RING_BUDGET_MB`; a non-finite or
23
+ * non-positive value falls back to the 16 MB default.
24
+ *
25
+ * The default is deliberately small (16 MB) so many concurrent per-camera rings
26
+ * fit inside a bounded `/dev/shm`. The decoder output is capped at 640px wide, so
27
+ * a slot is ≤ ~920 KB and 16 MB still yields ~17–70 latest-wins slots. A ring
28
+ * segment larger than the container's `/dev/shm` tmpfs backing (Docker default is
29
+ * only 64 MB) faults an **uncatchable SIGBUS** on write past `st_size` — the old
30
+ * 128 MB default overflowed a 64 MB `/dev/shm` and crashed the decoder on 8MP
31
+ * streams. Pair this with a `--shm-size` that scales with concurrent-decode load.
32
+ */
33
+ export declare const RING_BUDGET_MB: number;
34
+ /** {@link RING_BUDGET_MB} in bytes — the budget passed to `deriveSlotCount`. */
35
+ export declare const RING_BUDGET_BYTES: number;
36
+ /**
37
+ * Shared prefix for every decoder shm segment name. Startup orphan reclamation
38
+ * (`purgeOrphanSegments`) keys off this to find segments left behind by a
39
+ * crashed prior instance.
40
+ *
41
+ * macOS POSIX shm names are capped at ~31 characters (`PSHMNAMLEN`). A
42
+ * `camstack.frames.<deviceId>.<streamId>` scheme overflows that for realistic
43
+ * ids, so the sink uses a short, collision-resistant scheme instead:
44
+ * `csf.<base36 hash>.<gen>`. The hash folds the device id, the session tag
45
+ * and a per-process random salt; the generation suffix makes a re-created
46
+ * segment (resolution change) a distinct name so a stale consumer mapping is
47
+ * never silently reused.
48
+ */
49
+ export declare const SEGMENT_NAME_PREFIX: "csf.";
50
+ /**
51
+ * Build a stable, short shm segment name from a seed + generation, under a
52
+ * segment prefix. `prefix` defaults to {@link SEGMENT_NAME_PREFIX}; a distinct
53
+ * writer (e.g. the session-decode retention ring) passes its own prefix so its
54
+ * segments are never reclaimed by another writer's orphan-purge sweep.
55
+ */
56
+ export declare function makeSegmentName(seed: string, generation: number, prefix?: string): string;
57
+ /** Options for a `DecoderFrameRingSink`. */
58
+ export interface DecoderFrameRingSinkOptions {
59
+ /**
60
+ * Stable seed identifying this stream — folded into the segment name. The
61
+ * decoder passes `deviceId:tag` (or a random fallback); the sink adds a
62
+ * per-instance random salt so concurrent sessions never collide.
63
+ */
64
+ readonly seed: string;
65
+ readonly logger: IScopedLogger;
66
+ /**
67
+ * Cluster node id of the decoder that owns this ring. Stamped into every
68
+ * `FrameHandle` the writer produces so a downstream consumer knows which
69
+ * node's `decoder.getFrame` to route a read through.
70
+ */
71
+ readonly nodeId: string;
72
+ /**
73
+ * Segment name prefix. Defaults to {@link SEGMENT_NAME_PREFIX} (`csf.`). A
74
+ * non-decoder writer (session-decode retention ring) passes a distinct prefix
75
+ * (e.g. `csr.`) so its segments are isolated from the decoder's orphan-purge.
76
+ */
77
+ readonly segmentPrefix?: string;
78
+ /**
79
+ * Per-ring shared-memory budget in bytes. Defaults to {@link RING_BUDGET_BYTES}
80
+ * (`CAMSTACK_SHM_RING_BUDGET_MB`). A larger budget yields more latest-wins
81
+ * slots (deeper retention) for the same per-slot geometry.
82
+ */
83
+ readonly budgetBytes?: number;
84
+ }
85
+ /** shm ring usage snapshot — see {@link DecoderFrameRingSink.getShmStats}. */
86
+ export interface DecoderShmStats {
87
+ readonly slotCount: number;
88
+ readonly slotByteLength: number;
89
+ readonly segmentBytes: number;
90
+ readonly framesWritten: number;
91
+ }
92
+ /**
93
+ * The decoder-side owner of one stream's shared-memory frame ring.
94
+ *
95
+ * Not constructed until a session actually uses the shm sink; the segment
96
+ * itself is created lazily on the first `writeFrame`.
97
+ */
98
+ export declare class DecoderFrameRingSink {
99
+ private readonly seed;
100
+ private readonly logger;
101
+ private readonly nodeId;
102
+ private readonly segmentPrefix;
103
+ private readonly budgetBytes;
104
+ private segment;
105
+ private writer;
106
+ private segmentName;
107
+ private slotByteLength;
108
+ private generation;
109
+ private destroyed;
110
+ /** Frames committed into the ring across this sink's lifetime (all generations). */
111
+ private framesWritten;
112
+ constructor(options: DecoderFrameRingSinkOptions);
113
+ /** Whether a segment has been created (i.e. at least one frame written). */
114
+ get isArmed(): boolean;
115
+ /** The current segment name, or `null` before the first frame. */
116
+ get currentSegmentName(): string | null;
117
+ /**
118
+ * Write one decoded frame into the ring and return its `FrameHandle`.
119
+ *
120
+ * On the first call (or after a geometry change that overflows the current
121
+ * slot) the segment is created / re-created sized for this frame. Returns
122
+ * `null` only when the sink has been destroyed.
123
+ *
124
+ * This is the copy-in convenience form (it copies `pixels` into the slot).
125
+ * The decoder's hot path uses the zero-copy {@link beginFrame} /
126
+ * {@link commitFrame} scatter-write pair instead — the scaler produces its
127
+ * packed output directly into the slot, eliminating the write-side memcpy.
128
+ */
129
+ writeFrame(pixels: Buffer, meta: FrameMeta): FrameHandle | null;
130
+ /**
131
+ * Reserve a ring slot for a frame of the given geometry — the **zero-copy**
132
+ * scatter-write entry point (Phase 5 / D9 Task 7c).
133
+ *
134
+ * The segment is created / re-created here if this is the first frame or the
135
+ * geometry overflows the current slot capacity, so the slot is correctly
136
+ * sized before the caller fills it. The returned `buffer` is a writable view
137
+ * **directly over the mapped segment** — the node-av scaler scatters its
138
+ * packed output straight into it, with no intermediate copy. The caller MUST
139
+ * call {@link commitFrame} with the returned `slot` once the slot is filled.
140
+ *
141
+ * Returns `null` when the sink is destroyed or the segment cannot be created.
142
+ */
143
+ beginFrame(width: number, height: number, format: FrameMeta['format']): DecoderFrameSlot | null;
144
+ /**
145
+ * Publish the frame whose slot was reserved by {@link beginFrame} and filled
146
+ * in place by the caller. `slot` MUST be the value from the matching
147
+ * `beginFrame`. Returns the published `FrameHandle`, or `null` if the sink
148
+ * was destroyed (or the segment lost) between begin and commit.
149
+ */
150
+ commitFrame(slot: number, meta: FrameMeta): FrameHandle | null;
151
+ /**
152
+ * Current shm ring usage — `null` until the first frame arms the segment.
153
+ * Surfaced through `decoder.getShmStats` so a downstream consumer can
154
+ * observe ring pressure (slot depth, byte budget, frames written).
155
+ */
156
+ getShmStats(): DecoderShmStats | null;
157
+ /**
158
+ * Abandon a slot reserved by {@link beginFrame} **without publishing it** —
159
+ * the degenerate-path counterpart of {@link commitFrame}.
160
+ *
161
+ * A caller that reserved a slot but then could not produce valid pixels (no
162
+ * decoded source planes, or the scaler threw) MUST call this instead of
163
+ * `commitFrame`: it closes the open seqlock without advancing `writeIndex`,
164
+ * so no reader ever sees the slot's uninitialised bytes as a real frame, and
165
+ * no `FrameHandle` is handed downstream. `slot` MUST be the value from the
166
+ * matching `beginFrame`. A no-op if the sink was destroyed (or the segment
167
+ * lost) between begin and abort.
168
+ */
169
+ abortFrame(slot: number): void;
170
+ /** Close + unlink the segment. Idempotent. */
171
+ destroy(): void;
172
+ /**
173
+ * Create a fresh segment sized for at least `slotByteLength` bytes per slot,
174
+ * replacing any prior one. A re-create bumps the generation so the new
175
+ * segment has a distinct name — a consumer holding the old mapping is never
176
+ * silently handed a resized segment.
177
+ */
178
+ private recreateSegment;
179
+ /** Unmap + unlink the current segment, if any. */
180
+ private releaseSegment;
181
+ }
package/dist/index.d.ts CHANGED
@@ -10,3 +10,5 @@ export type { ShmSegment, NativeSegmentHandle } from './native.js';
10
10
  export { FrameRingWriter, FrameRingReader, computeSegmentSize, bytesPerPixel, computeSlotByteLength, deriveSlotCount, MIN_RING_SLOTS, MAX_RING_SLOTS, } from './frame-ring.js';
11
11
  export type { FrameMeta, FrameRead, FrameView, FrameSlotWrite } from './frame-ring.js';
12
12
  export { FrameRingReaderCache } from './frame-ring-reader-cache.js';
13
+ export { DecoderFrameRingSink, makeSegmentName, RING_BUDGET_MB, RING_BUDGET_BYTES, SEGMENT_NAME_PREFIX, } from './decoder-frame-ring-sink.js';
14
+ export type { DecoderFrameRingSinkOptions, DecoderFrameSlot, DecoderShmStats, } from './decoder-frame-ring-sink.js';
package/dist/index.js CHANGED
@@ -694,15 +694,307 @@ var FrameRingReaderCache = class {
694
694
  }
695
695
  };
696
696
  //#endregion
697
+ //#region src/decoder-frame-ring-sink.ts
698
+ /**
699
+ * `DecoderFrameRingSink` — the decoder's shared-memory write side (Phase 5 / D9).
700
+ *
701
+ * When a decoder session is configured with `frameSink: 'shm'`, the decoder
702
+ * **owns** the shared-memory ring segment for that stream: it creates the
703
+ * segment on the first decoded frame (when the output geometry is known),
704
+ * writes every subsequent decoded frame into the ring via a `FrameRingWriter`,
705
+ * and closes + unlinks the segment when the session is destroyed.
706
+ *
707
+ * What leaves the decoder is no longer the pixel `Buffer` — it is a tiny,
708
+ * serialisable `FrameHandle` (`FrameRingWriter.writeFrame`'s return value).
709
+ * Same-host consumers (motion, detection, the WebRTC encoder) open the same
710
+ * segment with a `FrameRingReader` and read the pixels zero-copy.
711
+ *
712
+ * ## Lazy segment creation
713
+ *
714
+ * The segment cannot be sized until the first frame: `slotByteLength` is
715
+ * `width × height × bytesPerPixel`, and the output dimensions are only known
716
+ * once the scaler has produced its first `dstFrame`. So `writeFrame` is a
717
+ * no-op-until-armed: the first call sizes + creates the segment, every later
718
+ * call writes into it.
719
+ *
720
+ * ## Resolution-change decision
721
+ *
722
+ * A live camera stream can change resolution mid-stream (the decoder's scaler
723
+ * is rebuilt on a config toggle, or the source renegotiates). The slot is
724
+ * sized for the **first** frame's geometry. A later frame that no longer fits
725
+ * the slot triggers a **segment re-create**: the old segment is closed +
726
+ * unlinked and a fresh, larger segment is created under a new generation-tagged
727
+ * name. This is simpler and leak-free versus over-allocating slots for a
728
+ * worst-case 4K frame on every stream; resolution changes on a live camera are
729
+ * rare, and a brief gap while consumers re-open the segment is acceptable
730
+ * (latest-wins — a missed frame is correct behaviour).
731
+ *
732
+ * ## Reuse note
733
+ *
734
+ * This lives in `@camstack/shm-ring` (a host-provided framework package) so both
735
+ * decoder addons (`addon-decoder-nodeav`, `addon-decoder-ffmpeg`) and the
736
+ * session-decode retention ring in `addon-pipeline` share one implementation.
737
+ * The `segmentPrefix` / `budgetBytes` options let a non-decoder writer (e.g. the
738
+ * session-decode retention ring) pick a distinct segment prefix and memory
739
+ * budget without forking the class.
740
+ */
741
+ /**
742
+ * Per-ring shared-memory budget (MB) — the slot count is derived per-resolution
743
+ * from this budget via {@link deriveSlotCount}, so a 360p stream gets many
744
+ * slots and a 4K stream a few, both inside the same memory footprint.
745
+ *
746
+ * Read ONCE at module load from `CAMSTACK_SHM_RING_BUDGET_MB`; a non-finite or
747
+ * non-positive value falls back to the 16 MB default.
748
+ *
749
+ * The default is deliberately small (16 MB) so many concurrent per-camera rings
750
+ * fit inside a bounded `/dev/shm`. The decoder output is capped at 640px wide, so
751
+ * a slot is ≤ ~920 KB and 16 MB still yields ~17–70 latest-wins slots. A ring
752
+ * segment larger than the container's `/dev/shm` tmpfs backing (Docker default is
753
+ * only 64 MB) faults an **uncatchable SIGBUS** on write past `st_size` — the old
754
+ * 128 MB default overflowed a 64 MB `/dev/shm` and crashed the decoder on 8MP
755
+ * streams. Pair this with a `--shm-size` that scales with concurrent-decode load.
756
+ */
757
+ var RING_BUDGET_MB = (() => {
758
+ const raw = Number(process.env["CAMSTACK_SHM_RING_BUDGET_MB"]);
759
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 16;
760
+ })();
761
+ /** {@link RING_BUDGET_MB} in bytes — the budget passed to `deriveSlotCount`. */
762
+ var RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
763
+ /**
764
+ * Shared prefix for every decoder shm segment name. Startup orphan reclamation
765
+ * (`purgeOrphanSegments`) keys off this to find segments left behind by a
766
+ * crashed prior instance.
767
+ *
768
+ * macOS POSIX shm names are capped at ~31 characters (`PSHMNAMLEN`). A
769
+ * `camstack.frames.<deviceId>.<streamId>` scheme overflows that for realistic
770
+ * ids, so the sink uses a short, collision-resistant scheme instead:
771
+ * `csf.<base36 hash>.<gen>`. The hash folds the device id, the session tag
772
+ * and a per-process random salt; the generation suffix makes a re-created
773
+ * segment (resolution change) a distinct name so a stale consumer mapping is
774
+ * never silently reused.
775
+ */
776
+ var SEGMENT_NAME_PREFIX = "csf.";
777
+ /**
778
+ * Build a stable, short shm segment name from a seed + generation, under a
779
+ * segment prefix. `prefix` defaults to {@link SEGMENT_NAME_PREFIX}; a distinct
780
+ * writer (e.g. the session-decode retention ring) passes its own prefix so its
781
+ * segments are never reclaimed by another writer's orphan-purge sweep.
782
+ */
783
+ function makeSegmentName(seed, generation, prefix = SEGMENT_NAME_PREFIX) {
784
+ let hash = 5381;
785
+ for (let i = 0; i < seed.length; i += 1) hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
786
+ return `${prefix}${(hash >>> 0).toString(36)}.${generation}`;
787
+ }
788
+ /**
789
+ * The decoder-side owner of one stream's shared-memory frame ring.
790
+ *
791
+ * Not constructed until a session actually uses the shm sink; the segment
792
+ * itself is created lazily on the first `writeFrame`.
793
+ */
794
+ var DecoderFrameRingSink = class {
795
+ seed;
796
+ logger;
797
+ nodeId;
798
+ segmentPrefix;
799
+ budgetBytes;
800
+ segment = null;
801
+ writer = null;
802
+ segmentName = null;
803
+ slotByteLength = 0;
804
+ generation = 0;
805
+ destroyed = false;
806
+ /** Frames committed into the ring across this sink's lifetime (all generations). */
807
+ framesWritten = 0;
808
+ constructor(options) {
809
+ const salt = Math.random().toString(36).slice(2, 8);
810
+ this.seed = `${options.seed}.${salt}`;
811
+ this.logger = options.logger;
812
+ this.nodeId = options.nodeId;
813
+ this.segmentPrefix = options.segmentPrefix ?? "csf.";
814
+ this.budgetBytes = options.budgetBytes !== void 0 && Number.isFinite(options.budgetBytes) && options.budgetBytes > 0 ? Math.floor(options.budgetBytes) : RING_BUDGET_BYTES;
815
+ }
816
+ /** Whether a segment has been created (i.e. at least one frame written). */
817
+ get isArmed() {
818
+ return this.writer !== null;
819
+ }
820
+ /** The current segment name, or `null` before the first frame. */
821
+ get currentSegmentName() {
822
+ return this.segmentName;
823
+ }
824
+ /**
825
+ * Write one decoded frame into the ring and return its `FrameHandle`.
826
+ *
827
+ * On the first call (or after a geometry change that overflows the current
828
+ * slot) the segment is created / re-created sized for this frame. Returns
829
+ * `null` only when the sink has been destroyed.
830
+ *
831
+ * This is the copy-in convenience form (it copies `pixels` into the slot).
832
+ * The decoder's hot path uses the zero-copy {@link beginFrame} /
833
+ * {@link commitFrame} scatter-write pair instead — the scaler produces its
834
+ * packed output directly into the slot, eliminating the write-side memcpy.
835
+ */
836
+ writeFrame(pixels, meta) {
837
+ if (this.destroyed) return null;
838
+ if (this.writer === null || computeSlotByteLength(meta.width, meta.height, meta.format) > this.slotByteLength) this.recreateSegment(computeSlotByteLength(meta.width, meta.height, meta.format));
839
+ const writer = this.writer;
840
+ if (writer === null) return null;
841
+ const handle = writer.writeFrame(pixels, meta);
842
+ this.framesWritten += 1;
843
+ return handle;
844
+ }
845
+ /**
846
+ * Reserve a ring slot for a frame of the given geometry — the **zero-copy**
847
+ * scatter-write entry point (Phase 5 / D9 Task 7c).
848
+ *
849
+ * The segment is created / re-created here if this is the first frame or the
850
+ * geometry overflows the current slot capacity, so the slot is correctly
851
+ * sized before the caller fills it. The returned `buffer` is a writable view
852
+ * **directly over the mapped segment** — the node-av scaler scatters its
853
+ * packed output straight into it, with no intermediate copy. The caller MUST
854
+ * call {@link commitFrame} with the returned `slot` once the slot is filled.
855
+ *
856
+ * Returns `null` when the sink is destroyed or the segment cannot be created.
857
+ */
858
+ beginFrame(width, height, format) {
859
+ if (this.destroyed) return null;
860
+ const requiredSlotBytes = computeSlotByteLength(width, height, format);
861
+ if (this.writer === null || requiredSlotBytes > this.slotByteLength) this.recreateSegment(requiredSlotBytes);
862
+ const writer = this.writer;
863
+ if (writer === null) return null;
864
+ const { slot, buffer } = writer.beginFrame();
865
+ return {
866
+ slot,
867
+ buffer
868
+ };
869
+ }
870
+ /**
871
+ * Publish the frame whose slot was reserved by {@link beginFrame} and filled
872
+ * in place by the caller. `slot` MUST be the value from the matching
873
+ * `beginFrame`. Returns the published `FrameHandle`, or `null` if the sink
874
+ * was destroyed (or the segment lost) between begin and commit.
875
+ */
876
+ commitFrame(slot, meta) {
877
+ if (this.destroyed) return null;
878
+ const writer = this.writer;
879
+ if (writer === null) return null;
880
+ const handle = writer.commitFrame(slot, meta);
881
+ this.framesWritten += 1;
882
+ return handle;
883
+ }
884
+ /**
885
+ * Current shm ring usage — `null` until the first frame arms the segment.
886
+ * Surfaced through `decoder.getShmStats` so a downstream consumer can
887
+ * observe ring pressure (slot depth, byte budget, frames written).
888
+ */
889
+ getShmStats() {
890
+ if (this.writer === null) return null;
891
+ return {
892
+ slotCount: this.writer.slotCount,
893
+ slotByteLength: this.slotByteLength,
894
+ segmentBytes: computeSegmentSize(this.writer.slotCount, this.slotByteLength),
895
+ framesWritten: this.framesWritten
896
+ };
897
+ }
898
+ /**
899
+ * Abandon a slot reserved by {@link beginFrame} **without publishing it** —
900
+ * the degenerate-path counterpart of {@link commitFrame}.
901
+ *
902
+ * A caller that reserved a slot but then could not produce valid pixels (no
903
+ * decoded source planes, or the scaler threw) MUST call this instead of
904
+ * `commitFrame`: it closes the open seqlock without advancing `writeIndex`,
905
+ * so no reader ever sees the slot's uninitialised bytes as a real frame, and
906
+ * no `FrameHandle` is handed downstream. `slot` MUST be the value from the
907
+ * matching `beginFrame`. A no-op if the sink was destroyed (or the segment
908
+ * lost) between begin and abort.
909
+ */
910
+ abortFrame(slot) {
911
+ if (this.destroyed) return;
912
+ const writer = this.writer;
913
+ if (writer === null) return;
914
+ writer.abortFrame(slot);
915
+ }
916
+ /** Close + unlink the segment. Idempotent. */
917
+ destroy() {
918
+ if (this.destroyed) return;
919
+ this.destroyed = true;
920
+ this.releaseSegment();
921
+ }
922
+ /**
923
+ * Create a fresh segment sized for at least `slotByteLength` bytes per slot,
924
+ * replacing any prior one. A re-create bumps the generation so the new
925
+ * segment has a distinct name — a consumer holding the old mapping is never
926
+ * silently handed a resized segment.
927
+ */
928
+ recreateSegment(slotByteLength) {
929
+ this.releaseSegment();
930
+ this.generation += 1;
931
+ const name = makeSegmentName(this.seed, this.generation, this.segmentPrefix);
932
+ const slotCount = deriveSlotCount(this.budgetBytes, slotByteLength);
933
+ if (slotCount === 6 && 6 * slotByteLength > this.budgetBytes) this.logger.warn("decoder shm ring: budget too small for resolution — using MIN slots", { meta: {
934
+ slotByteLength,
935
+ budgetBytes: this.budgetBytes
936
+ } });
937
+ const totalBytes = computeSegmentSize(slotCount, slotByteLength);
938
+ try {
939
+ const segment = createSegment(name, totalBytes);
940
+ this.segment = segment;
941
+ this.segmentName = name;
942
+ this.slotByteLength = slotByteLength;
943
+ this.writer = new FrameRingWriter(segment.buffer, name, slotCount, slotByteLength, this.nodeId);
944
+ this.logger.info("decoder shm ring: segment created", { meta: {
945
+ segment: name,
946
+ slotCount,
947
+ slotByteLength,
948
+ totalBytes,
949
+ generation: this.generation
950
+ } });
951
+ } catch (err) {
952
+ this.segment = null;
953
+ this.writer = null;
954
+ this.segmentName = null;
955
+ this.slotByteLength = 0;
956
+ this.logger.error("decoder shm ring: segment create failed", { meta: {
957
+ segment: name,
958
+ slotByteLength,
959
+ error: err instanceof Error ? err.message : String(err)
960
+ } });
961
+ }
962
+ }
963
+ /** Unmap + unlink the current segment, if any. */
964
+ releaseSegment() {
965
+ const segment = this.segment;
966
+ if (segment === null) return;
967
+ this.segment = null;
968
+ this.writer = null;
969
+ const name = this.segmentName;
970
+ this.segmentName = null;
971
+ try {
972
+ segment.close();
973
+ segment.unlink();
974
+ this.logger.info("decoder shm ring: segment released", { meta: { segment: name } });
975
+ } catch (err) {
976
+ this.logger.warn("decoder shm ring: segment release failed", { meta: {
977
+ segment: name,
978
+ error: err instanceof Error ? err.message : String(err)
979
+ } });
980
+ }
981
+ }
982
+ };
983
+ //#endregion
984
+ exports.DecoderFrameRingSink = DecoderFrameRingSink;
697
985
  exports.FrameRingReader = FrameRingReader;
698
986
  exports.FrameRingReaderCache = FrameRingReaderCache;
699
987
  exports.FrameRingWriter = FrameRingWriter;
700
988
  exports.MAX_RING_SLOTS = MAX_RING_SLOTS;
701
989
  exports.MIN_RING_SLOTS = MIN_RING_SLOTS;
990
+ exports.RING_BUDGET_BYTES = RING_BUDGET_BYTES;
991
+ exports.RING_BUDGET_MB = RING_BUDGET_MB;
992
+ exports.SEGMENT_NAME_PREFIX = SEGMENT_NAME_PREFIX;
702
993
  exports.bytesPerPixel = bytesPerPixel;
703
994
  exports.computeSegmentSize = computeSegmentSize;
704
995
  exports.computeSlotByteLength = computeSlotByteLength;
705
996
  exports.createSegment = createSegment;
706
997
  exports.deriveSlotCount = deriveSlotCount;
998
+ exports.makeSegmentName = makeSegmentName;
707
999
  exports.openSegment = openSegment;
708
1000
  exports.unlinkSegment = unlinkSegment;
package/dist/index.mjs CHANGED
@@ -693,4 +693,291 @@ var FrameRingReaderCache = class {
693
693
  }
694
694
  };
695
695
  //#endregion
696
- export { FrameRingReader, FrameRingReaderCache, FrameRingWriter, MAX_RING_SLOTS, MIN_RING_SLOTS, bytesPerPixel, computeSegmentSize, computeSlotByteLength, createSegment, deriveSlotCount, openSegment, unlinkSegment };
696
+ //#region src/decoder-frame-ring-sink.ts
697
+ /**
698
+ * `DecoderFrameRingSink` — the decoder's shared-memory write side (Phase 5 / D9).
699
+ *
700
+ * When a decoder session is configured with `frameSink: 'shm'`, the decoder
701
+ * **owns** the shared-memory ring segment for that stream: it creates the
702
+ * segment on the first decoded frame (when the output geometry is known),
703
+ * writes every subsequent decoded frame into the ring via a `FrameRingWriter`,
704
+ * and closes + unlinks the segment when the session is destroyed.
705
+ *
706
+ * What leaves the decoder is no longer the pixel `Buffer` — it is a tiny,
707
+ * serialisable `FrameHandle` (`FrameRingWriter.writeFrame`'s return value).
708
+ * Same-host consumers (motion, detection, the WebRTC encoder) open the same
709
+ * segment with a `FrameRingReader` and read the pixels zero-copy.
710
+ *
711
+ * ## Lazy segment creation
712
+ *
713
+ * The segment cannot be sized until the first frame: `slotByteLength` is
714
+ * `width × height × bytesPerPixel`, and the output dimensions are only known
715
+ * once the scaler has produced its first `dstFrame`. So `writeFrame` is a
716
+ * no-op-until-armed: the first call sizes + creates the segment, every later
717
+ * call writes into it.
718
+ *
719
+ * ## Resolution-change decision
720
+ *
721
+ * A live camera stream can change resolution mid-stream (the decoder's scaler
722
+ * is rebuilt on a config toggle, or the source renegotiates). The slot is
723
+ * sized for the **first** frame's geometry. A later frame that no longer fits
724
+ * the slot triggers a **segment re-create**: the old segment is closed +
725
+ * unlinked and a fresh, larger segment is created under a new generation-tagged
726
+ * name. This is simpler and leak-free versus over-allocating slots for a
727
+ * worst-case 4K frame on every stream; resolution changes on a live camera are
728
+ * rare, and a brief gap while consumers re-open the segment is acceptable
729
+ * (latest-wins — a missed frame is correct behaviour).
730
+ *
731
+ * ## Reuse note
732
+ *
733
+ * This lives in `@camstack/shm-ring` (a host-provided framework package) so both
734
+ * decoder addons (`addon-decoder-nodeav`, `addon-decoder-ffmpeg`) and the
735
+ * session-decode retention ring in `addon-pipeline` share one implementation.
736
+ * The `segmentPrefix` / `budgetBytes` options let a non-decoder writer (e.g. the
737
+ * session-decode retention ring) pick a distinct segment prefix and memory
738
+ * budget without forking the class.
739
+ */
740
+ /**
741
+ * Per-ring shared-memory budget (MB) — the slot count is derived per-resolution
742
+ * from this budget via {@link deriveSlotCount}, so a 360p stream gets many
743
+ * slots and a 4K stream a few, both inside the same memory footprint.
744
+ *
745
+ * Read ONCE at module load from `CAMSTACK_SHM_RING_BUDGET_MB`; a non-finite or
746
+ * non-positive value falls back to the 16 MB default.
747
+ *
748
+ * The default is deliberately small (16 MB) so many concurrent per-camera rings
749
+ * fit inside a bounded `/dev/shm`. The decoder output is capped at 640px wide, so
750
+ * a slot is ≤ ~920 KB and 16 MB still yields ~17–70 latest-wins slots. A ring
751
+ * segment larger than the container's `/dev/shm` tmpfs backing (Docker default is
752
+ * only 64 MB) faults an **uncatchable SIGBUS** on write past `st_size` — the old
753
+ * 128 MB default overflowed a 64 MB `/dev/shm` and crashed the decoder on 8MP
754
+ * streams. Pair this with a `--shm-size` that scales with concurrent-decode load.
755
+ */
756
+ var RING_BUDGET_MB = (() => {
757
+ const raw = Number(process.env["CAMSTACK_SHM_RING_BUDGET_MB"]);
758
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 16;
759
+ })();
760
+ /** {@link RING_BUDGET_MB} in bytes — the budget passed to `deriveSlotCount`. */
761
+ var RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
762
+ /**
763
+ * Shared prefix for every decoder shm segment name. Startup orphan reclamation
764
+ * (`purgeOrphanSegments`) keys off this to find segments left behind by a
765
+ * crashed prior instance.
766
+ *
767
+ * macOS POSIX shm names are capped at ~31 characters (`PSHMNAMLEN`). A
768
+ * `camstack.frames.<deviceId>.<streamId>` scheme overflows that for realistic
769
+ * ids, so the sink uses a short, collision-resistant scheme instead:
770
+ * `csf.<base36 hash>.<gen>`. The hash folds the device id, the session tag
771
+ * and a per-process random salt; the generation suffix makes a re-created
772
+ * segment (resolution change) a distinct name so a stale consumer mapping is
773
+ * never silently reused.
774
+ */
775
+ var SEGMENT_NAME_PREFIX = "csf.";
776
+ /**
777
+ * Build a stable, short shm segment name from a seed + generation, under a
778
+ * segment prefix. `prefix` defaults to {@link SEGMENT_NAME_PREFIX}; a distinct
779
+ * writer (e.g. the session-decode retention ring) passes its own prefix so its
780
+ * segments are never reclaimed by another writer's orphan-purge sweep.
781
+ */
782
+ function makeSegmentName(seed, generation, prefix = SEGMENT_NAME_PREFIX) {
783
+ let hash = 5381;
784
+ for (let i = 0; i < seed.length; i += 1) hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
785
+ return `${prefix}${(hash >>> 0).toString(36)}.${generation}`;
786
+ }
787
+ /**
788
+ * The decoder-side owner of one stream's shared-memory frame ring.
789
+ *
790
+ * Not constructed until a session actually uses the shm sink; the segment
791
+ * itself is created lazily on the first `writeFrame`.
792
+ */
793
+ var DecoderFrameRingSink = class {
794
+ seed;
795
+ logger;
796
+ nodeId;
797
+ segmentPrefix;
798
+ budgetBytes;
799
+ segment = null;
800
+ writer = null;
801
+ segmentName = null;
802
+ slotByteLength = 0;
803
+ generation = 0;
804
+ destroyed = false;
805
+ /** Frames committed into the ring across this sink's lifetime (all generations). */
806
+ framesWritten = 0;
807
+ constructor(options) {
808
+ const salt = Math.random().toString(36).slice(2, 8);
809
+ this.seed = `${options.seed}.${salt}`;
810
+ this.logger = options.logger;
811
+ this.nodeId = options.nodeId;
812
+ this.segmentPrefix = options.segmentPrefix ?? "csf.";
813
+ this.budgetBytes = options.budgetBytes !== void 0 && Number.isFinite(options.budgetBytes) && options.budgetBytes > 0 ? Math.floor(options.budgetBytes) : RING_BUDGET_BYTES;
814
+ }
815
+ /** Whether a segment has been created (i.e. at least one frame written). */
816
+ get isArmed() {
817
+ return this.writer !== null;
818
+ }
819
+ /** The current segment name, or `null` before the first frame. */
820
+ get currentSegmentName() {
821
+ return this.segmentName;
822
+ }
823
+ /**
824
+ * Write one decoded frame into the ring and return its `FrameHandle`.
825
+ *
826
+ * On the first call (or after a geometry change that overflows the current
827
+ * slot) the segment is created / re-created sized for this frame. Returns
828
+ * `null` only when the sink has been destroyed.
829
+ *
830
+ * This is the copy-in convenience form (it copies `pixels` into the slot).
831
+ * The decoder's hot path uses the zero-copy {@link beginFrame} /
832
+ * {@link commitFrame} scatter-write pair instead — the scaler produces its
833
+ * packed output directly into the slot, eliminating the write-side memcpy.
834
+ */
835
+ writeFrame(pixels, meta) {
836
+ if (this.destroyed) return null;
837
+ if (this.writer === null || computeSlotByteLength(meta.width, meta.height, meta.format) > this.slotByteLength) this.recreateSegment(computeSlotByteLength(meta.width, meta.height, meta.format));
838
+ const writer = this.writer;
839
+ if (writer === null) return null;
840
+ const handle = writer.writeFrame(pixels, meta);
841
+ this.framesWritten += 1;
842
+ return handle;
843
+ }
844
+ /**
845
+ * Reserve a ring slot for a frame of the given geometry — the **zero-copy**
846
+ * scatter-write entry point (Phase 5 / D9 Task 7c).
847
+ *
848
+ * The segment is created / re-created here if this is the first frame or the
849
+ * geometry overflows the current slot capacity, so the slot is correctly
850
+ * sized before the caller fills it. The returned `buffer` is a writable view
851
+ * **directly over the mapped segment** — the node-av scaler scatters its
852
+ * packed output straight into it, with no intermediate copy. The caller MUST
853
+ * call {@link commitFrame} with the returned `slot` once the slot is filled.
854
+ *
855
+ * Returns `null` when the sink is destroyed or the segment cannot be created.
856
+ */
857
+ beginFrame(width, height, format) {
858
+ if (this.destroyed) return null;
859
+ const requiredSlotBytes = computeSlotByteLength(width, height, format);
860
+ if (this.writer === null || requiredSlotBytes > this.slotByteLength) this.recreateSegment(requiredSlotBytes);
861
+ const writer = this.writer;
862
+ if (writer === null) return null;
863
+ const { slot, buffer } = writer.beginFrame();
864
+ return {
865
+ slot,
866
+ buffer
867
+ };
868
+ }
869
+ /**
870
+ * Publish the frame whose slot was reserved by {@link beginFrame} and filled
871
+ * in place by the caller. `slot` MUST be the value from the matching
872
+ * `beginFrame`. Returns the published `FrameHandle`, or `null` if the sink
873
+ * was destroyed (or the segment lost) between begin and commit.
874
+ */
875
+ commitFrame(slot, meta) {
876
+ if (this.destroyed) return null;
877
+ const writer = this.writer;
878
+ if (writer === null) return null;
879
+ const handle = writer.commitFrame(slot, meta);
880
+ this.framesWritten += 1;
881
+ return handle;
882
+ }
883
+ /**
884
+ * Current shm ring usage — `null` until the first frame arms the segment.
885
+ * Surfaced through `decoder.getShmStats` so a downstream consumer can
886
+ * observe ring pressure (slot depth, byte budget, frames written).
887
+ */
888
+ getShmStats() {
889
+ if (this.writer === null) return null;
890
+ return {
891
+ slotCount: this.writer.slotCount,
892
+ slotByteLength: this.slotByteLength,
893
+ segmentBytes: computeSegmentSize(this.writer.slotCount, this.slotByteLength),
894
+ framesWritten: this.framesWritten
895
+ };
896
+ }
897
+ /**
898
+ * Abandon a slot reserved by {@link beginFrame} **without publishing it** —
899
+ * the degenerate-path counterpart of {@link commitFrame}.
900
+ *
901
+ * A caller that reserved a slot but then could not produce valid pixels (no
902
+ * decoded source planes, or the scaler threw) MUST call this instead of
903
+ * `commitFrame`: it closes the open seqlock without advancing `writeIndex`,
904
+ * so no reader ever sees the slot's uninitialised bytes as a real frame, and
905
+ * no `FrameHandle` is handed downstream. `slot` MUST be the value from the
906
+ * matching `beginFrame`. A no-op if the sink was destroyed (or the segment
907
+ * lost) between begin and abort.
908
+ */
909
+ abortFrame(slot) {
910
+ if (this.destroyed) return;
911
+ const writer = this.writer;
912
+ if (writer === null) return;
913
+ writer.abortFrame(slot);
914
+ }
915
+ /** Close + unlink the segment. Idempotent. */
916
+ destroy() {
917
+ if (this.destroyed) return;
918
+ this.destroyed = true;
919
+ this.releaseSegment();
920
+ }
921
+ /**
922
+ * Create a fresh segment sized for at least `slotByteLength` bytes per slot,
923
+ * replacing any prior one. A re-create bumps the generation so the new
924
+ * segment has a distinct name — a consumer holding the old mapping is never
925
+ * silently handed a resized segment.
926
+ */
927
+ recreateSegment(slotByteLength) {
928
+ this.releaseSegment();
929
+ this.generation += 1;
930
+ const name = makeSegmentName(this.seed, this.generation, this.segmentPrefix);
931
+ const slotCount = deriveSlotCount(this.budgetBytes, slotByteLength);
932
+ if (slotCount === 6 && 6 * slotByteLength > this.budgetBytes) this.logger.warn("decoder shm ring: budget too small for resolution — using MIN slots", { meta: {
933
+ slotByteLength,
934
+ budgetBytes: this.budgetBytes
935
+ } });
936
+ const totalBytes = computeSegmentSize(slotCount, slotByteLength);
937
+ try {
938
+ const segment = createSegment(name, totalBytes);
939
+ this.segment = segment;
940
+ this.segmentName = name;
941
+ this.slotByteLength = slotByteLength;
942
+ this.writer = new FrameRingWriter(segment.buffer, name, slotCount, slotByteLength, this.nodeId);
943
+ this.logger.info("decoder shm ring: segment created", { meta: {
944
+ segment: name,
945
+ slotCount,
946
+ slotByteLength,
947
+ totalBytes,
948
+ generation: this.generation
949
+ } });
950
+ } catch (err) {
951
+ this.segment = null;
952
+ this.writer = null;
953
+ this.segmentName = null;
954
+ this.slotByteLength = 0;
955
+ this.logger.error("decoder shm ring: segment create failed", { meta: {
956
+ segment: name,
957
+ slotByteLength,
958
+ error: err instanceof Error ? err.message : String(err)
959
+ } });
960
+ }
961
+ }
962
+ /** Unmap + unlink the current segment, if any. */
963
+ releaseSegment() {
964
+ const segment = this.segment;
965
+ if (segment === null) return;
966
+ this.segment = null;
967
+ this.writer = null;
968
+ const name = this.segmentName;
969
+ this.segmentName = null;
970
+ try {
971
+ segment.close();
972
+ segment.unlink();
973
+ this.logger.info("decoder shm ring: segment released", { meta: { segment: name } });
974
+ } catch (err) {
975
+ this.logger.warn("decoder shm ring: segment release failed", { meta: {
976
+ segment: name,
977
+ error: err instanceof Error ? err.message : String(err)
978
+ } });
979
+ }
980
+ }
981
+ };
982
+ //#endregion
983
+ export { DecoderFrameRingSink, FrameRingReader, FrameRingReaderCache, FrameRingWriter, MAX_RING_SLOTS, MIN_RING_SLOTS, RING_BUDGET_BYTES, RING_BUDGET_MB, SEGMENT_NAME_PREFIX, bytesPerPixel, computeSegmentSize, computeSlotByteLength, createSegment, deriveSlotCount, makeSegmentName, openSegment, unlinkSegment };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/shm-ring",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "description": "CamStack shared-memory frame ring — cross-platform N-API segment mapping + seqlock ring",
5
5
  "keywords": [
6
6
  "camstack",