@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.
@@ -1572,6 +1572,14 @@ var init_BaichuanVideoStream = __esm({
1572
1572
  logger;
1573
1573
  active = false;
1574
1574
  videoFrameHandler;
1575
+ // Fired when the underlying BaichuanClient socket closes/errors (e.g. the
1576
+ // keepalive watchdog tears down a half-open socket after a camera reboot).
1577
+ // Without this, the stream keeps believing it is active and only the idle
1578
+ // watchdog would (eventually, and uselessly) try to restart on a dead
1579
+ // client. Surfacing the socket death as a stream "close" lets the consumer
1580
+ // (createNativeStream → fanout onEnd → BaichuanRtspServer) rebuild the
1581
+ // stream on a fresh dedicated session.
1582
+ clientCloseHandler;
1575
1583
  expectedStreamTypes;
1576
1584
  activeMsgNum;
1577
1585
  cmdId;
@@ -2452,6 +2460,19 @@ var init_BaichuanVideoStream = __esm({
2452
2460
  }
2453
2461
  };
2454
2462
  this.client.on("push", this.videoFrameHandler);
2463
+ this.clientCloseHandler = () => {
2464
+ if (!this.active) return;
2465
+ this.logger?.warn?.(
2466
+ `[BaichuanVideoStream] underlying socket closed (channel=${this.channel} profile=${this.profile}) \u2014 ending stream for rebuild`
2467
+ );
2468
+ this.active = false;
2469
+ this.stopWatchdog();
2470
+ if (this.videoFrameHandler)
2471
+ this.client.off("push", this.videoFrameHandler);
2472
+ this.videoFrameHandler = void 0;
2473
+ this.emit("close");
2474
+ };
2475
+ this.client.on("close", this.clientCloseHandler);
2455
2476
  this.active = true;
2456
2477
  this.startWatchdog();
2457
2478
  if (this.api && typeof this.api._registerVideoStreamForDetection === "function") {
@@ -2522,6 +2543,10 @@ var init_BaichuanVideoStream = __esm({
2522
2543
  if (this.videoFrameHandler)
2523
2544
  this.client.off("push", this.videoFrameHandler);
2524
2545
  this.videoFrameHandler = void 0;
2546
+ if (this.clientCloseHandler) {
2547
+ this.client.off("close", this.clientCloseHandler);
2548
+ this.clientCloseHandler = void 0;
2549
+ }
2525
2550
  throw err;
2526
2551
  }
2527
2552
  this.emitSafeError(err);
@@ -2542,6 +2567,10 @@ var init_BaichuanVideoStream = __esm({
2542
2567
  this.client.removeListener("push", this.videoFrameHandler);
2543
2568
  }
2544
2569
  this.videoFrameHandler = void 0;
2570
+ if (this.clientCloseHandler) {
2571
+ this.client.removeListener("close", this.clientCloseHandler);
2572
+ this.clientCloseHandler = void 0;
2573
+ }
2545
2574
  this.bcMediaCodec.clear();
2546
2575
  this.activeMsgNum = void 0;
2547
2576
  if (this.api) {
@@ -9081,6 +9110,20 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events3.E
9081
9110
  // from the last IDR in the prebuffer immediately.
9082
9111
  PREBUFFER_MAX_MS = 3e3;
9083
9112
  prebuffer = [];
9113
+ /**
9114
+ * Hard cap on the userspace TCP send buffer for a single RTSP client. A
9115
+ * healthy consumer drains near-instantly; if this much data backs up the
9116
+ * client is dead or far too slow to keep up with the live stream.
9117
+ */
9118
+ static MAX_CLIENT_BUFFERED_BYTES = 8 * 1024 * 1024;
9119
+ /**
9120
+ * Pure backpressure decision: should the client socket be disconnected
9121
+ * because its send buffer has grown past the hard cap? Extracted so the
9122
+ * decision can be unit-tested without a live socket.
9123
+ */
9124
+ static shouldDisconnectForBackpressure(bufferedBytes) {
9125
+ return bufferedBytes > _BaichuanRtspServer.MAX_CLIENT_BUFFERED_BYTES;
9126
+ }
9084
9127
  static isAdtsAacFrame(b) {
9085
9128
  return b.length >= 2 && b[0] === 255 && (b[1] & 240) === 240;
9086
9129
  }
@@ -10057,14 +10100,36 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events3.E
10057
10100
  const rtspDebugLog = (message) => this.rtspDebugLog(message);
10058
10101
  const sendInterleaved = (channel, msg) => {
10059
10102
  if (!rtspSocket || rtspSocket.destroyed || !rtspSocket.writable)
10060
- return false;
10103
+ return true;
10104
+ if (_BaichuanRtspServer.shouldDisconnectForBackpressure(
10105
+ rtspSocket.writableLength
10106
+ )) {
10107
+ this.logger.warn(
10108
+ `[rebroadcast] backpressure: ${Math.round(rtspSocket.writableLength / 1024)}KB buffered for client=${clientId} \u2014 disconnecting`
10109
+ );
10110
+ rtspSocket.destroy();
10111
+ return true;
10112
+ }
10061
10113
  try {
10062
10114
  return rtspSocket.write(frameRtpOverTcp(channel, msg));
10063
10115
  } catch (error) {
10064
10116
  if (error && typeof error === "object" && "code" in error && error.code === "EPIPE")
10065
- return false;
10117
+ return true;
10066
10118
  }
10067
- return false;
10119
+ return true;
10120
+ };
10121
+ const awaitClientDrain = async () => {
10122
+ if (!rtspSocket || rtspSocket.destroyed) return;
10123
+ if (!rtspSocket.writableNeedDrain) return;
10124
+ await new Promise((resolve) => {
10125
+ const done = () => {
10126
+ rtspSocket.removeListener("drain", done);
10127
+ rtspSocket.removeListener("close", done);
10128
+ resolve();
10129
+ };
10130
+ rtspSocket.once("drain", done);
10131
+ rtspSocket.once("close", done);
10132
+ });
10068
10133
  };
10069
10134
  const getVideoChannel = () => resources?.track0RtpChannel ?? 0;
10070
10135
  const getAudioChannel = () => resources?.track1RtpChannel ?? 2;
@@ -10643,6 +10708,7 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events3.E
10643
10708
  resources.framesSent = (resources.framesSent ?? 0) + 1;
10644
10709
  }
10645
10710
  sendVideoAccessUnit(videoType, normalizedVideoData, true);
10711
+ await awaitClientDrain();
10646
10712
  } else {
10647
10713
  try {
10648
10714
  if (stdin && !stdin.destroyed && !stdin.writableEnded && !stdin.writableFinished) {
@@ -13763,6 +13829,16 @@ var BaichuanClient = class _BaichuanClient extends import_node_events5.EventEmit
13763
13829
  // Public to allow ReolinkBaichuanApi to check subscription status
13764
13830
  keepAliveTimer;
13765
13831
  keepAlivePingInFlight = false;
13832
+ // Consecutive TCP keepalive (PING) failures with no response. A half-open
13833
+ // socket after a camera reboot is NOT reported as `destroyed`, so
13834
+ // `isSocketConnected()` stays stale-true and recovery never triggers. We
13835
+ // treat repeated unanswered pings as a hard liveness failure and tear the
13836
+ // socket down so the 'close' handler fires (resetting loggedIn/subscribed
13837
+ // and emitting "close"), which lets ensureConnected() rebuild it.
13838
+ consecutiveKeepalivePingFailures = 0;
13839
+ // Number of unanswered keepalive pings tolerated before the TCP socket is
13840
+ // declared dead. 2 misses ≈ up to ~2 keepalive intervals of silence.
13841
+ static KEEPALIVE_MAX_PING_FAILURES = 2;
13766
13842
  // Battery/BCUDP cameras may actively terminate sessions (D2C_DISC) when overwhelmed
13767
13843
  // or sleeping. Without a cooldown, callers can end up in tight reconnect loops.
13768
13844
  udpReconnectCooldownUntilMs = 0;
@@ -14391,8 +14467,21 @@ var BaichuanClient = class _BaichuanClient extends import_node_events5.EventEmit
14391
14467
  // Keepalive has empty body
14392
14468
  internal: true
14393
14469
  });
14470
+ this.consecutiveKeepalivePingFailures = 0;
14394
14471
  } catch (e) {
14395
- this.logDebug("keepalive_ping_failed", e);
14472
+ this.consecutiveKeepalivePingFailures++;
14473
+ this.logDebug("keepalive_ping_failed", {
14474
+ error: e,
14475
+ consecutiveFailures: this.consecutiveKeepalivePingFailures
14476
+ });
14477
+ if (this.transport === "tcp" && this.consecutiveKeepalivePingFailures >= _BaichuanClient.KEEPALIVE_MAX_PING_FAILURES && this.tcpSocket && !this.tcpSocket.destroyed) {
14478
+ this.logFixed(
14479
+ "keepalive_dead",
14480
+ `transport=tcp host=${this.opts.host} consecutiveFailures=${this.consecutiveKeepalivePingFailures} \u2014 destroying half-open socket`
14481
+ );
14482
+ this.consecutiveKeepalivePingFailures = 0;
14483
+ this.tcpSocket.destroy();
14484
+ }
14396
14485
  }
14397
14486
  }
14398
14487
  async connect() {
@@ -14485,6 +14574,7 @@ var BaichuanClient = class _BaichuanClient extends import_node_events5.EventEmit
14485
14574
  this.tcpSocket = sock;
14486
14575
  this.transport = "tcp";
14487
14576
  this.socketClosed = false;
14577
+ this.consecutiveKeepalivePingFailures = 0;
14488
14578
  this.socketSessionId = this.newSocketSessionId("tcp");
14489
14579
  try {
14490
14580
  sock.setKeepAlive(true, 3e4);
@@ -22486,9 +22576,45 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
22486
22576
  const generalEntry = this.socketPool.get("general");
22487
22577
  if (!generalEntry) return;
22488
22578
  if (!generalEntry.client.isSocketConnected?.() || !generalEntry.client.loggedIn) {
22489
- this.logger.debug?.(
22490
- `[ReolinkBaichuanApi] event watchdog tick: skipping (connection not alive: connected=${generalEntry.client.isSocketConnected?.()} loggedIn=${generalEntry.client.loggedIn})`
22579
+ if (!this.eventWatchdogSilenceResubscribeEnabled) {
22580
+ this.logger.debug?.(
22581
+ `[ReolinkBaichuanApi] event watchdog tick: skipping (connection not alive, battery/UDP \u2014 not forcing reconnect: connected=${generalEntry.client.isSocketConnected?.()} loggedIn=${generalEntry.client.loggedIn})`
22582
+ );
22583
+ return;
22584
+ }
22585
+ const now2 = Date.now();
22586
+ const backoffMs = Math.min(
22587
+ 3e4 * Math.pow(2, this.simpleEventWatchdogRecoveryAttempts),
22588
+ this.simpleEventWatchdogSilenceThresholdMs
22491
22589
  );
22590
+ if (now2 - this.simpleEventWatchdogLastRecoveryAt < backoffMs) {
22591
+ this.logger.debug?.(
22592
+ `[ReolinkBaichuanApi] event watchdog tick: connection down, reconnect backoff active (connected=${generalEntry.client.isSocketConnected?.()} loggedIn=${generalEntry.client.loggedIn})`
22593
+ );
22594
+ return;
22595
+ }
22596
+ this.simpleEventWatchdogRecoveryAttempts++;
22597
+ this.simpleEventWatchdogLastRecoveryAt = now2;
22598
+ (this.logger.info ?? this.logger.log).call(
22599
+ this.logger,
22600
+ `[ReolinkBaichuanApi] event watchdog: control socket down, forcing full reconnect (attempt #${this.simpleEventWatchdogRecoveryAttempts})`,
22601
+ { host: this.host }
22602
+ );
22603
+ try {
22604
+ await this.ensureConnected();
22605
+ this.simpleEventLastReceivedAt = Date.now();
22606
+ this.simpleEventWatchdogRecoveryAttempts = 0;
22607
+ (this.logger.info ?? this.logger.log).call(
22608
+ this.logger,
22609
+ `[ReolinkBaichuanApi] event watchdog: full reconnect successful`
22610
+ );
22611
+ } catch (e) {
22612
+ (this.logger.debug ?? this.logger.log).call(
22613
+ this.logger,
22614
+ `[ReolinkBaichuanApi] event watchdog: full reconnect attempt #${this.simpleEventWatchdogRecoveryAttempts} failed`,
22615
+ formatErrorForLog(e)
22616
+ );
22617
+ }
22492
22618
  return;
22493
22619
  }
22494
22620
  const now = Date.now();