@apocaliss92/nodelink-js 0.6.7 → 0.6.8-beta.0

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.
@@ -3,11 +3,11 @@ import {
3
3
  BaichuanRtspServer,
4
4
  ReolinkBaichuanApi,
5
5
  autoDetectDeviceType
6
- } from "../chunk-T22QCNBR.js";
7
- import "../chunk-IQVVVSXO.js";
6
+ } from "../chunk-OJ4YR45R.js";
7
+ import "../chunk-27ZKJZFH.js";
8
8
  import {
9
9
  __require
10
- } from "../chunk-MZUSWKF3.js";
10
+ } from "../chunk-4LZABN6I.js";
11
11
 
12
12
  // src/cli/rtsp-server.ts
13
13
  function parseArgs() {
package/dist/index.cjs CHANGED
@@ -2311,6 +2311,14 @@ var init_BaichuanVideoStream = __esm({
2311
2311
  logger;
2312
2312
  active = false;
2313
2313
  videoFrameHandler;
2314
+ // Fired when the underlying BaichuanClient socket closes/errors (e.g. the
2315
+ // keepalive watchdog tears down a half-open socket after a camera reboot).
2316
+ // Without this, the stream keeps believing it is active and only the idle
2317
+ // watchdog would (eventually, and uselessly) try to restart on a dead
2318
+ // client. Surfacing the socket death as a stream "close" lets the consumer
2319
+ // (createNativeStream → fanout onEnd → BaichuanRtspServer) rebuild the
2320
+ // stream on a fresh dedicated session.
2321
+ clientCloseHandler;
2314
2322
  expectedStreamTypes;
2315
2323
  activeMsgNum;
2316
2324
  cmdId;
@@ -3191,6 +3199,19 @@ var init_BaichuanVideoStream = __esm({
3191
3199
  }
3192
3200
  };
3193
3201
  this.client.on("push", this.videoFrameHandler);
3202
+ this.clientCloseHandler = () => {
3203
+ if (!this.active) return;
3204
+ this.logger?.warn?.(
3205
+ `[BaichuanVideoStream] underlying socket closed (channel=${this.channel} profile=${this.profile}) \u2014 ending stream for rebuild`
3206
+ );
3207
+ this.active = false;
3208
+ this.stopWatchdog();
3209
+ if (this.videoFrameHandler)
3210
+ this.client.off("push", this.videoFrameHandler);
3211
+ this.videoFrameHandler = void 0;
3212
+ this.emit("close");
3213
+ };
3214
+ this.client.on("close", this.clientCloseHandler);
3194
3215
  this.active = true;
3195
3216
  this.startWatchdog();
3196
3217
  if (this.api && typeof this.api._registerVideoStreamForDetection === "function") {
@@ -3261,6 +3282,10 @@ var init_BaichuanVideoStream = __esm({
3261
3282
  if (this.videoFrameHandler)
3262
3283
  this.client.off("push", this.videoFrameHandler);
3263
3284
  this.videoFrameHandler = void 0;
3285
+ if (this.clientCloseHandler) {
3286
+ this.client.off("close", this.clientCloseHandler);
3287
+ this.clientCloseHandler = void 0;
3288
+ }
3264
3289
  throw err;
3265
3290
  }
3266
3291
  this.emitSafeError(err);
@@ -3281,6 +3306,10 @@ var init_BaichuanVideoStream = __esm({
3281
3306
  this.client.removeListener("push", this.videoFrameHandler);
3282
3307
  }
3283
3308
  this.videoFrameHandler = void 0;
3309
+ if (this.clientCloseHandler) {
3310
+ this.client.removeListener("close", this.clientCloseHandler);
3311
+ this.clientCloseHandler = void 0;
3312
+ }
3284
3313
  this.bcMediaCodec.clear();
3285
3314
  this.activeMsgNum = void 0;
3286
3315
  if (this.api) {
@@ -10916,6 +10945,16 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10916
10945
  // Public to allow ReolinkBaichuanApi to check subscription status
10917
10946
  keepAliveTimer;
10918
10947
  keepAlivePingInFlight = false;
10948
+ // Consecutive TCP keepalive (PING) failures with no response. A half-open
10949
+ // socket after a camera reboot is NOT reported as `destroyed`, so
10950
+ // `isSocketConnected()` stays stale-true and recovery never triggers. We
10951
+ // treat repeated unanswered pings as a hard liveness failure and tear the
10952
+ // socket down so the 'close' handler fires (resetting loggedIn/subscribed
10953
+ // and emitting "close"), which lets ensureConnected() rebuild it.
10954
+ consecutiveKeepalivePingFailures = 0;
10955
+ // Number of unanswered keepalive pings tolerated before the TCP socket is
10956
+ // declared dead. 2 misses ≈ up to ~2 keepalive intervals of silence.
10957
+ static KEEPALIVE_MAX_PING_FAILURES = 2;
10919
10958
  // Battery/BCUDP cameras may actively terminate sessions (D2C_DISC) when overwhelmed
10920
10959
  // or sleeping. Without a cooldown, callers can end up in tight reconnect loops.
10921
10960
  udpReconnectCooldownUntilMs = 0;
@@ -11544,8 +11583,21 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
11544
11583
  // Keepalive has empty body
11545
11584
  internal: true
11546
11585
  });
11586
+ this.consecutiveKeepalivePingFailures = 0;
11547
11587
  } catch (e) {
11548
- this.logDebug("keepalive_ping_failed", e);
11588
+ this.consecutiveKeepalivePingFailures++;
11589
+ this.logDebug("keepalive_ping_failed", {
11590
+ error: e,
11591
+ consecutiveFailures: this.consecutiveKeepalivePingFailures
11592
+ });
11593
+ if (this.transport === "tcp" && this.consecutiveKeepalivePingFailures >= _BaichuanClient.KEEPALIVE_MAX_PING_FAILURES && this.tcpSocket && !this.tcpSocket.destroyed) {
11594
+ this.logFixed(
11595
+ "keepalive_dead",
11596
+ `transport=tcp host=${this.opts.host} consecutiveFailures=${this.consecutiveKeepalivePingFailures} \u2014 destroying half-open socket`
11597
+ );
11598
+ this.consecutiveKeepalivePingFailures = 0;
11599
+ this.tcpSocket.destroy();
11600
+ }
11549
11601
  }
11550
11602
  }
11551
11603
  async connect() {
@@ -11638,6 +11690,7 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
11638
11690
  this.tcpSocket = sock;
11639
11691
  this.transport = "tcp";
11640
11692
  this.socketClosed = false;
11693
+ this.consecutiveKeepalivePingFailures = 0;
11641
11694
  this.socketSessionId = this.newSocketSessionId("tcp");
11642
11695
  try {
11643
11696
  sock.setKeepAlive(true, 3e4);
@@ -15142,6 +15195,20 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events5.E
15142
15195
  // from the last IDR in the prebuffer immediately.
15143
15196
  PREBUFFER_MAX_MS = 3e3;
15144
15197
  prebuffer = [];
15198
+ /**
15199
+ * Hard cap on the userspace TCP send buffer for a single RTSP client. A
15200
+ * healthy consumer drains near-instantly; if this much data backs up the
15201
+ * client is dead or far too slow to keep up with the live stream.
15202
+ */
15203
+ static MAX_CLIENT_BUFFERED_BYTES = 8 * 1024 * 1024;
15204
+ /**
15205
+ * Pure backpressure decision: should the client socket be disconnected
15206
+ * because its send buffer has grown past the hard cap? Extracted so the
15207
+ * decision can be unit-tested without a live socket.
15208
+ */
15209
+ static shouldDisconnectForBackpressure(bufferedBytes) {
15210
+ return bufferedBytes > _BaichuanRtspServer.MAX_CLIENT_BUFFERED_BYTES;
15211
+ }
15145
15212
  static isAdtsAacFrame(b) {
15146
15213
  return b.length >= 2 && b[0] === 255 && (b[1] & 240) === 240;
15147
15214
  }
@@ -16118,14 +16185,36 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events5.E
16118
16185
  const rtspDebugLog = (message) => this.rtspDebugLog(message);
16119
16186
  const sendInterleaved = (channel, msg) => {
16120
16187
  if (!rtspSocket || rtspSocket.destroyed || !rtspSocket.writable)
16121
- return false;
16188
+ return true;
16189
+ if (_BaichuanRtspServer.shouldDisconnectForBackpressure(
16190
+ rtspSocket.writableLength
16191
+ )) {
16192
+ this.logger.warn(
16193
+ `[rebroadcast] backpressure: ${Math.round(rtspSocket.writableLength / 1024)}KB buffered for client=${clientId} \u2014 disconnecting`
16194
+ );
16195
+ rtspSocket.destroy();
16196
+ return true;
16197
+ }
16122
16198
  try {
16123
16199
  return rtspSocket.write(frameRtpOverTcp(channel, msg));
16124
16200
  } catch (error) {
16125
16201
  if (error && typeof error === "object" && "code" in error && error.code === "EPIPE")
16126
- return false;
16202
+ return true;
16127
16203
  }
16128
- return false;
16204
+ return true;
16205
+ };
16206
+ const awaitClientDrain = async () => {
16207
+ if (!rtspSocket || rtspSocket.destroyed) return;
16208
+ if (!rtspSocket.writableNeedDrain) return;
16209
+ await new Promise((resolve) => {
16210
+ const done = () => {
16211
+ rtspSocket.removeListener("drain", done);
16212
+ rtspSocket.removeListener("close", done);
16213
+ resolve();
16214
+ };
16215
+ rtspSocket.once("drain", done);
16216
+ rtspSocket.once("close", done);
16217
+ });
16129
16218
  };
16130
16219
  const getVideoChannel = () => resources?.track0RtpChannel ?? 0;
16131
16220
  const getAudioChannel = () => resources?.track1RtpChannel ?? 2;
@@ -16704,6 +16793,7 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events5.E
16704
16793
  resources.framesSent = (resources.framesSent ?? 0) + 1;
16705
16794
  }
16706
16795
  sendVideoAccessUnit(videoType, normalizedVideoData, true);
16796
+ await awaitClientDrain();
16707
16797
  } else {
16708
16798
  try {
16709
16799
  if (stdin && !stdin.destroyed && !stdin.writableEnded && !stdin.writableFinished) {
@@ -23189,9 +23279,45 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
23189
23279
  const generalEntry = this.socketPool.get("general");
23190
23280
  if (!generalEntry) return;
23191
23281
  if (!generalEntry.client.isSocketConnected?.() || !generalEntry.client.loggedIn) {
23192
- this.logger.debug?.(
23193
- `[ReolinkBaichuanApi] event watchdog tick: skipping (connection not alive: connected=${generalEntry.client.isSocketConnected?.()} loggedIn=${generalEntry.client.loggedIn})`
23282
+ if (!this.eventWatchdogSilenceResubscribeEnabled) {
23283
+ this.logger.debug?.(
23284
+ `[ReolinkBaichuanApi] event watchdog tick: skipping (connection not alive, battery/UDP \u2014 not forcing reconnect: connected=${generalEntry.client.isSocketConnected?.()} loggedIn=${generalEntry.client.loggedIn})`
23285
+ );
23286
+ return;
23287
+ }
23288
+ const now2 = Date.now();
23289
+ const backoffMs = Math.min(
23290
+ 3e4 * Math.pow(2, this.simpleEventWatchdogRecoveryAttempts),
23291
+ this.simpleEventWatchdogSilenceThresholdMs
23194
23292
  );
23293
+ if (now2 - this.simpleEventWatchdogLastRecoveryAt < backoffMs) {
23294
+ this.logger.debug?.(
23295
+ `[ReolinkBaichuanApi] event watchdog tick: connection down, reconnect backoff active (connected=${generalEntry.client.isSocketConnected?.()} loggedIn=${generalEntry.client.loggedIn})`
23296
+ );
23297
+ return;
23298
+ }
23299
+ this.simpleEventWatchdogRecoveryAttempts++;
23300
+ this.simpleEventWatchdogLastRecoveryAt = now2;
23301
+ (this.logger.info ?? this.logger.log).call(
23302
+ this.logger,
23303
+ `[ReolinkBaichuanApi] event watchdog: control socket down, forcing full reconnect (attempt #${this.simpleEventWatchdogRecoveryAttempts})`,
23304
+ { host: this.host }
23305
+ );
23306
+ try {
23307
+ await this.ensureConnected();
23308
+ this.simpleEventLastReceivedAt = Date.now();
23309
+ this.simpleEventWatchdogRecoveryAttempts = 0;
23310
+ (this.logger.info ?? this.logger.log).call(
23311
+ this.logger,
23312
+ `[ReolinkBaichuanApi] event watchdog: full reconnect successful`
23313
+ );
23314
+ } catch (e) {
23315
+ (this.logger.debug ?? this.logger.log).call(
23316
+ this.logger,
23317
+ `[ReolinkBaichuanApi] event watchdog: full reconnect attempt #${this.simpleEventWatchdogRecoveryAttempts} failed`,
23318
+ formatErrorForLog(e)
23319
+ );
23320
+ }
23195
23321
  return;
23196
23322
  }
23197
23323
  const now = Date.now();