signalk-edge-link 2.1.0 → 2.2.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.
package/lib/index.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const createRoutes = require("./routes");
4
4
  const instance_1 = require("./instance");
5
5
  const connection_config_1 = require("./connection-config");
6
+ const connection_schema_1 = require("./shared/connection-schema");
6
7
  const pkg = require("../package.json");
7
8
  module.exports = function createPlugin(app) {
8
9
  const plugin = {};
@@ -200,460 +201,11 @@ module.exports = function createPlugin(app) {
200
201
  // ── Schema (array-based) ─────────────────────────────────────────────────
201
202
  //
202
203
  // Each item in the `connections` array is a full connection configuration.
203
- // The schema retains complete backward-compat: Signal K's RJSF renderer
204
- // renders arrays as an add/remove list automatically.
205
- //
206
- // Client-only fields are shown via `dependencies` on `serverType`, matching
207
- // the original single-connection schema pattern.
208
- const connectionItemSchema = {
209
- type: "object",
210
- title: "Connection",
211
- required: ["serverType", "udpPort", "secretKey"],
212
- properties: {
213
- name: {
214
- type: "string",
215
- title: "Connection Name",
216
- description: "Human-readable label for this connection (e.g. 'Shore Server', 'Sat Client'). Used to namespace config files and Signal K metrics paths.",
217
- default: "connection",
218
- maxLength: 40
219
- },
220
- serverType: {
221
- type: "string",
222
- title: "Operation Mode",
223
- description: "Select Server to receive data, or Client to send data",
224
- default: "client",
225
- oneOf: [
226
- { const: "server", title: "Server Mode – Receive Data" },
227
- { const: "client", title: "Client Mode – Send Data" }
228
- ]
229
- },
230
- udpPort: {
231
- type: "number",
232
- title: "UDP Port",
233
- description: "UDP port for data transmission (must match on both ends)",
234
- default: 4446,
235
- minimum: 1024,
236
- maximum: 65535
237
- },
238
- secretKey: {
239
- type: "string",
240
- title: "Encryption Key",
241
- description: "32-byte secret key: 32-character ASCII, 64-character hex, or 44-character base64",
242
- minLength: 32,
243
- maxLength: 64,
244
- pattern: "^(?:.{32}|[0-9a-fA-F]{64}|[A-Za-z0-9+/]{43}=?)$"
245
- },
246
- useMsgpack: {
247
- type: "boolean",
248
- title: "Use MessagePack",
249
- description: "Binary serialization for smaller payloads (must match on both ends)",
250
- default: false
251
- },
252
- usePathDictionary: {
253
- type: "boolean",
254
- title: "Use Path Dictionary",
255
- description: "Encode paths as numeric IDs for bandwidth savings (must match on both ends)",
256
- default: false
257
- },
258
- protocolVersion: {
259
- type: "number",
260
- title: "Protocol Version",
261
- description: "v1: encrypted UDP. v2 adds reliable delivery and metrics. v3 keeps the v2 data path and authenticates control packets (ACK/NAK/HEARTBEAT/HELLO). Must match on both ends.",
262
- default: 1,
263
- oneOf: [
264
- { const: 1, title: "v1 – Standard encrypted UDP" },
265
- { const: 2, title: "v2 – Reliability, congestion control, bonding, metrics" },
266
- { const: 3, title: "v3 - v2 features with authenticated control packets" }
267
- ]
268
- }
269
- },
270
- dependencies: {
271
- serverType: {
272
- oneOf: [
273
- {
274
- properties: {
275
- serverType: { enum: ["server"] },
276
- reliability: {
277
- type: "object",
278
- title: "Reliability Settings (v2/v3 only)",
279
- description: "Requires Protocol v2 or v3. Controls ACK/NAK timing for reliable delivery",
280
- properties: {
281
- ackInterval: {
282
- type: "number",
283
- title: "ACK Interval (ms)",
284
- description: "How often server sends cumulative ACK updates",
285
- default: 100,
286
- minimum: 20,
287
- maximum: 5000
288
- },
289
- ackResendInterval: {
290
- type: "number",
291
- title: "ACK Resend Interval (ms)",
292
- description: "Re-send duplicate ACK periodically to recover from lost ACK packets",
293
- default: 1000,
294
- minimum: 100,
295
- maximum: 10000
296
- },
297
- nakTimeout: {
298
- type: "number",
299
- title: "NAK Timeout (ms)",
300
- description: "Delay before requesting retransmission for missing sequence numbers",
301
- default: 100,
302
- minimum: 20,
303
- maximum: 5000
304
- }
305
- }
306
- }
307
- }
308
- },
309
- {
310
- properties: {
311
- serverType: { enum: ["client"] },
312
- udpAddress: {
313
- type: "string",
314
- title: "Server Address",
315
- description: "IP address or hostname of the SignalK server",
316
- default: "127.0.0.1"
317
- },
318
- helloMessageSender: {
319
- type: "integer",
320
- title: "Heartbeat Interval (seconds)",
321
- description: "How often to send heartbeat messages",
322
- default: 60,
323
- minimum: 10,
324
- maximum: 3600
325
- },
326
- testAddress: {
327
- type: "string",
328
- title: "Connectivity Test Address",
329
- description: "Address to ping for network testing (e.g., 8.8.8.8)",
330
- default: "127.0.0.1"
331
- },
332
- testPort: {
333
- type: "number",
334
- title: "Connectivity Test Port",
335
- description: "Port for connectivity test (80, 443, 53)",
336
- default: 80,
337
- minimum: 1,
338
- maximum: 65535
339
- },
340
- pingIntervalTime: {
341
- type: "number",
342
- title: "Check Interval (minutes)",
343
- description: "How often to test network connectivity",
344
- default: 1,
345
- minimum: 0.1,
346
- maximum: 60
347
- },
348
- heartbeatInterval: {
349
- type: "number",
350
- title: "NAT Keepalive Heartbeat Interval (ms)",
351
- description: "v2/v3 only. How often to send UDP heartbeat packets for NAT traversal. Typical NAT timeouts range from 30s to 120s.",
352
- default: 25000,
353
- minimum: 5000,
354
- maximum: 120000
355
- },
356
- reliability: {
357
- type: "object",
358
- title: "Reliability Settings (v2/v3 only)",
359
- description: "Requires Protocol v2 or v3. Controls retransmit queue behavior and packet retry limits",
360
- properties: {
361
- retransmitQueueSize: {
362
- type: "number",
363
- title: "Retransmit Queue Size",
364
- description: "Maximum number of sent packets stored for potential retransmission",
365
- default: 5000,
366
- minimum: 100,
367
- maximum: 50000
368
- },
369
- maxRetransmits: {
370
- type: "number",
371
- title: "Max Retransmit Attempts",
372
- description: "Maximum resend attempts before a packet is dropped from the retransmit queue",
373
- default: 3,
374
- minimum: 1,
375
- maximum: 20
376
- },
377
- retransmitMaxAge: {
378
- type: "number",
379
- title: "Retransmit Max Age (ms)",
380
- description: "Expire stale unacknowledged packets older than this age",
381
- default: 120000,
382
- minimum: 1000,
383
- maximum: 300000
384
- },
385
- retransmitMinAge: {
386
- type: "number",
387
- title: "Retransmit Min Age (ms)",
388
- description: "Minimum packet age before expiration is allowed",
389
- default: 10000,
390
- minimum: 200,
391
- maximum: 30000
392
- },
393
- retransmitRttMultiplier: {
394
- type: "number",
395
- title: "RTT Expiry Multiplier",
396
- description: "Dynamic expiry age is adjusted to RTT x this multiplier",
397
- default: 12,
398
- minimum: 2,
399
- maximum: 20
400
- },
401
- ackIdleDrainAge: {
402
- type: "number",
403
- title: "ACK Idle Drain Age (ms)",
404
- description: "If ACKs are idle longer than this, expiry becomes more aggressive",
405
- default: 20000,
406
- minimum: 500,
407
- maximum: 30000
408
- },
409
- forceDrainAfterAckIdle: {
410
- type: "boolean",
411
- title: "Force Drain After ACK Idle",
412
- description: "When enabled, clear retransmit queue if no ACKs arrive for too long",
413
- default: false
414
- },
415
- forceDrainAfterMs: {
416
- type: "number",
417
- title: "Force Drain Timeout (ms)",
418
- description: "ACK idle duration before force-draining retransmit queue to zero",
419
- default: 45000,
420
- minimum: 2000,
421
- maximum: 120000
422
- },
423
- recoveryBurstEnabled: {
424
- type: "boolean",
425
- title: "Recovery Burst Enabled",
426
- description: "When ACKs return after outage, rapidly retransmit queued packets to catch up",
427
- default: true
428
- },
429
- recoveryBurstSize: {
430
- type: "number",
431
- title: "Recovery Burst Size",
432
- description: "Max queued packets to retransmit per recovery burst cycle",
433
- default: 100,
434
- minimum: 10,
435
- maximum: 1000
436
- },
437
- recoveryBurstIntervalMs: {
438
- type: "number",
439
- title: "Recovery Burst Interval (ms)",
440
- description: "Interval between recovery burst cycles while backlog exists",
441
- default: 200,
442
- minimum: 50,
443
- maximum: 5000
444
- },
445
- recoveryAckGapMs: {
446
- type: "number",
447
- title: "Recovery ACK Gap (ms)",
448
- description: "Minimum ACK silence before triggering fast recovery bursts",
449
- default: 4000,
450
- minimum: 500,
451
- maximum: 120000
452
- }
453
- }
454
- },
455
- congestionControl: {
456
- type: "object",
457
- title: "Dynamic Congestion Control (v2/v3 only)",
458
- description: "Requires Protocol v2 or v3. AIMD algorithm to dynamically adjust send rate based on network conditions",
459
- properties: {
460
- enabled: {
461
- type: "boolean",
462
- title: "Enable Congestion Control",
463
- description: "Automatically adjust delta timer based on RTT and packet loss",
464
- default: false
465
- },
466
- targetRTT: {
467
- type: "number",
468
- title: "Target RTT (ms)",
469
- description: "RTT threshold above which send rate is reduced",
470
- default: 200,
471
- minimum: 50,
472
- maximum: 2000
473
- },
474
- nominalDeltaTimer: {
475
- type: "number",
476
- title: "Nominal Delta Timer (ms)",
477
- description: "Preferred steady-state send interval",
478
- default: 1000,
479
- minimum: 100,
480
- maximum: 10000
481
- },
482
- minDeltaTimer: {
483
- type: "number",
484
- title: "Minimum Delta Timer (ms)",
485
- description: "Fastest allowed send interval",
486
- default: 100,
487
- minimum: 50,
488
- maximum: 1000
489
- },
490
- maxDeltaTimer: {
491
- type: "number",
492
- title: "Maximum Delta Timer (ms)",
493
- description: "Slowest allowed send interval",
494
- default: 5000,
495
- minimum: 1000,
496
- maximum: 30000
497
- }
498
- }
499
- },
500
- bonding: {
501
- type: "object",
502
- title: "Connection Bonding (v2/v3 only)",
503
- description: "Requires Protocol v2 or v3. Dual-link bonding with automatic failover between primary and backup connections",
504
- properties: {
505
- enabled: {
506
- type: "boolean",
507
- title: "Enable Connection Bonding",
508
- description: "Enable dual-link bonding with automatic failover",
509
- default: false
510
- },
511
- mode: {
512
- type: "string",
513
- title: "Bonding Mode",
514
- description: "Bonding operating mode",
515
- default: "main-backup",
516
- oneOf: [
517
- {
518
- const: "main-backup",
519
- title: "Main/Backup – Failover to backup when primary degrades"
520
- }
521
- ]
522
- },
523
- primary: {
524
- type: "object",
525
- title: "Primary Link",
526
- description: "Primary connection (e.g., LTE modem)",
527
- properties: {
528
- address: { type: "string", title: "Server Address", default: "127.0.0.1" },
529
- port: {
530
- type: "number",
531
- title: "UDP Port",
532
- default: 4446,
533
- minimum: 1024,
534
- maximum: 65535
535
- },
536
- interface: { type: "string", title: "Bind Interface (optional)" }
537
- }
538
- },
539
- backup: {
540
- type: "object",
541
- title: "Backup Link",
542
- description: "Backup connection (e.g., Starlink, satellite)",
543
- properties: {
544
- address: { type: "string", title: "Server Address", default: "127.0.0.1" },
545
- port: {
546
- type: "number",
547
- title: "UDP Port",
548
- default: 4447,
549
- minimum: 1024,
550
- maximum: 65535
551
- },
552
- interface: { type: "string", title: "Bind Interface (optional)" }
553
- }
554
- },
555
- failover: {
556
- type: "object",
557
- title: "Failover Thresholds",
558
- description: "Configure when failover is triggered",
559
- properties: {
560
- rttThreshold: {
561
- type: "number",
562
- title: "RTT Threshold (ms)",
563
- default: 500,
564
- minimum: 100,
565
- maximum: 5000
566
- },
567
- lossThreshold: {
568
- type: "number",
569
- title: "Packet Loss Threshold",
570
- default: 0.1,
571
- minimum: 0.01,
572
- maximum: 0.5
573
- },
574
- healthCheckInterval: {
575
- type: "number",
576
- title: "Health Check Interval (ms)",
577
- default: 1000,
578
- minimum: 500,
579
- maximum: 10000
580
- },
581
- failbackDelay: {
582
- type: "number",
583
- title: "Failback Delay (ms)",
584
- default: 30000,
585
- minimum: 5000,
586
- maximum: 300000
587
- },
588
- heartbeatTimeout: {
589
- type: "number",
590
- title: "Heartbeat Timeout (ms)",
591
- default: 5000,
592
- minimum: 1000,
593
- maximum: 30000
594
- }
595
- }
596
- }
597
- }
598
- },
599
- alertThresholds: {
600
- type: "object",
601
- title: "Monitoring Alert Thresholds (v2/v3 only)",
602
- description: "Customize warning/critical thresholds for network monitoring alerts",
603
- properties: {
604
- rtt: {
605
- type: "object",
606
- title: "RTT Thresholds",
607
- properties: {
608
- warning: { type: "number", title: "Warning RTT (ms)", default: 300 },
609
- critical: { type: "number", title: "Critical RTT (ms)", default: 800 }
610
- }
611
- },
612
- packetLoss: {
613
- type: "object",
614
- title: "Packet Loss Thresholds",
615
- properties: {
616
- warning: { type: "number", title: "Warning Loss Ratio", default: 0.03 },
617
- critical: { type: "number", title: "Critical Loss Ratio", default: 0.1 }
618
- }
619
- },
620
- retransmitRate: {
621
- type: "object",
622
- title: "Retransmit Rate Thresholds",
623
- properties: {
624
- warning: { type: "number", title: "Warning Retransmit Ratio", default: 0.05 },
625
- critical: {
626
- type: "number",
627
- title: "Critical Retransmit Ratio",
628
- default: 0.15
629
- }
630
- }
631
- },
632
- jitter: {
633
- type: "object",
634
- title: "Jitter Thresholds",
635
- properties: {
636
- warning: { type: "number", title: "Warning Jitter (ms)", default: 100 },
637
- critical: { type: "number", title: "Critical Jitter (ms)", default: 300 }
638
- }
639
- },
640
- queueDepth: {
641
- type: "object",
642
- title: "Queue Depth Thresholds",
643
- properties: {
644
- warning: { type: "number", title: "Warning Queue Depth", default: 100 },
645
- critical: { type: "number", title: "Critical Queue Depth", default: 500 }
646
- }
647
- }
648
- }
649
- }
650
- },
651
- required: ["udpAddress", "testAddress", "testPort"]
652
- }
653
- ]
654
- }
655
- }
656
- };
204
+ // The field definitions live in `src/shared/connection-schema.ts` and are
205
+ // consumed unchanged here and by the webapp RJSF form in
206
+ // `src/webapp/components/PluginConfigurationPanel.tsx` — there is a single
207
+ // schema source.
208
+ const connectionItemSchema = (0, connection_schema_1.buildConnectionItemSchema)();
657
209
  plugin.schema = {
658
210
  type: "object",
659
211
  title: "SignalK Edge Link",
package/lib/instance.js CHANGED
@@ -93,7 +93,6 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
93
93
  pipelineServer: null,
94
94
  heartbeatHandle: null,
95
95
  monitoring: null,
96
- networkSimulator: null,
97
96
  configDebounceTimers: {},
98
97
  configContentHashes: {},
99
98
  configWatcherObjects: [],
@@ -269,7 +268,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
269
268
  }
270
269
  finally {
271
270
  state.batchSendInFlight = false;
272
- if (state.deltas.length > 0 && !state.pendingRetry) {
271
+ if (state.deltas.length > 0 && !state.pendingRetry && !state.stopped) {
273
272
  setImmediate(() => {
274
273
  flushDeltaBatch();
275
274
  });
@@ -780,7 +779,8 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
780
779
  failover: options.bonding.failover || {},
781
780
  instanceId: state.instanceId,
782
781
  notificationsEnabled: options.enableNotifications === true,
783
- secretKey: options.secretKey
782
+ secretKey: options.secretKey,
783
+ stretchAsciiKey: !!options.stretchAsciiKey
784
784
  };
785
785
  try {
786
786
  await v2Pipeline.initBonding(bondingConfig);
@@ -884,7 +884,6 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
884
884
  }
885
885
  state.monitoring = null;
886
886
  }
887
- state.networkSimulator = null;
888
887
  // Stop ping monitor
889
888
  if (state.pingMonitor) {
890
889
  state.pingMonitor.stop();
package/lib/packet.js CHANGED
@@ -120,11 +120,14 @@ class PacketBuilder {
120
120
  /**
121
121
  * @param {Object} [config]
122
122
  * @param {number} [config.initialSequence=0] - Starting sequence number
123
+ * @param {boolean} [config.stretchAsciiKey=false] - When true, 32-char
124
+ * ASCII keys are stretched via PBKDF2 before use. Both ends must agree.
123
125
  */
124
126
  constructor(config = {}) {
125
127
  this._sequence = config.initialSequence ?? 0;
126
128
  this._protocolVersion = normalizeProtocolVersion(config.protocolVersion);
127
129
  this._secretKey = config.secretKey || null;
130
+ this._stretchAsciiKey = !!config.stretchAsciiKey;
128
131
  }
129
132
  /**
130
133
  * Build a DATA packet
@@ -258,7 +261,7 @@ class PacketBuilder {
258
261
  throw new Error("Protocol v3 control packets require a secretKey");
259
262
  }
260
263
  header.writeUInt32BE(payloadBuffer.length + crypto_1.CONTROL_AUTH_TAG_LENGTH, 9);
261
- const authTag = (0, crypto_1.createControlPacketAuthTag)(header.subarray(0, 13), payloadBuffer, secretKey);
264
+ const authTag = (0, crypto_1.createControlPacketAuthTag)(header.subarray(0, 13), payloadBuffer, secretKey, { stretchAsciiKey: this._stretchAsciiKey });
262
265
  finalPayload = Buffer.concat([payloadBuffer, authTag]);
263
266
  }
264
267
  else if (payloadBuffer.length > 0) {
@@ -290,13 +293,15 @@ exports.PacketBuilder = PacketBuilder;
290
293
  class PacketParser {
291
294
  constructor(config = {}) {
292
295
  this._secretKey = config.secretKey || null;
296
+ this._stretchAsciiKey = !!config.stretchAsciiKey;
293
297
  }
294
298
  /**
295
299
  * Parse a packet header
296
300
  * @param {Buffer} packet - Raw packet data
297
301
  * @param {Object} [options]
298
302
  * @param {string} [options.secretKey] - Required for v3 control packets
299
- * @param {boolean} [options.allowUnauthenticatedControl=false] - Skip v3 control auth verification
303
+ * @param {boolean} [options.stretchAsciiKey] - Override the parser's
304
+ * constructor-time setting; both ends must agree
300
305
  * @returns {Object} Parsed packet information
301
306
  * @throws {Error} If packet is invalid
302
307
  */
@@ -353,13 +358,14 @@ class PacketParser {
353
358
  }
354
359
  const payloadData = payload.subarray(0, payload.length - crypto_1.CONTROL_AUTH_TAG_LENGTH);
355
360
  const authTag = payload.subarray(payload.length - crypto_1.CONTROL_AUTH_TAG_LENGTH);
356
- if (!options.allowUnauthenticatedControl) {
357
- const secretKey = options.secretKey || this._secretKey;
358
- if (!secretKey) {
359
- throw new Error("Control packet authentication requires secretKey");
360
- }
361
- (0, crypto_1.verifyControlPacketAuthTag)(packet.subarray(0, 13), payloadData, authTag, secretKey);
361
+ const secretKey = options.secretKey || this._secretKey;
362
+ if (!secretKey) {
363
+ throw new Error("Control packet authentication requires secretKey");
362
364
  }
365
+ const stretchAsciiKey = options.stretchAsciiKey ?? this._stretchAsciiKey;
366
+ (0, crypto_1.verifyControlPacketAuthTag)(packet.subarray(0, 13), payloadData, authTag, secretKey, {
367
+ stretchAsciiKey
368
+ });
363
369
  payload = payloadData;
364
370
  }
365
371
  else {
@@ -37,12 +37,15 @@ function createPipelineV2Client(app, state, metricsApi) {
37
37
  const { metrics, recordError, trackPathStats, updateBandwidthRates } = metricsApi;
38
38
  const setStatus = app.setPluginStatus || app.setProviderStatus || (() => { });
39
39
  const protocolVersion = state.options && state.options.protocolVersion === 3 ? 3 : 2;
40
+ const stretchAsciiKey = !!state.options?.stretchAsciiKey;
40
41
  const packetBuilder = new packet_1.PacketBuilder({
41
42
  protocolVersion,
42
- secretKey: state.options?.secretKey ?? undefined
43
+ secretKey: state.options?.secretKey ?? undefined,
44
+ stretchAsciiKey
43
45
  });
44
46
  const packetParser = new packet_1.PacketParser({
45
- secretKey: state.options?.secretKey ?? undefined
47
+ secretKey: state.options?.secretKey ?? undefined,
48
+ stretchAsciiKey
46
49
  });
47
50
  const clientTelemetrySource = "signalk-edge-link-client-telemetry";
48
51
  // Reliability: extract config once to avoid repetitive deep-access chains
@@ -140,8 +143,11 @@ function createPipelineV2Client(app, state, metricsApi) {
140
143
  }
141
144
  function _effectiveRetransmitAge() {
142
145
  let maxAge = retransmitMaxAge;
143
- if ((metrics.rtt ?? 0) > 0) {
144
- const rttBasedAge = Math.round((metrics.rtt ?? 0) * retransmitRttMultiplier);
146
+ // Use the congestion controller's smoothed RTT (EMA) instead of the raw
147
+ // latest sample to avoid volatile timeout swings from single RTT spikes.
148
+ const smoothedRtt = congestionControl.getAvgRTT();
149
+ if (smoothedRtt > 0) {
150
+ const rttBasedAge = Math.round(smoothedRtt * retransmitRttMultiplier);
145
151
  maxAge = Math.min(maxAge, Math.max(retransmitMinAge, rttBasedAge));
146
152
  }
147
153
  const ackIdleMs = Date.now() - lastAckAt;
@@ -266,7 +272,9 @@ function createPipelineV2Client(app, state, metricsApi) {
266
272
  // Compress
267
273
  const compressed = await (0, pipeline_utils_1.compressPayload)(serialized, state.options?.useMsgpack ?? false);
268
274
  // Encrypt
269
- const encrypted = (0, crypto_1.encryptBinary)(compressed, secretKey);
275
+ const encrypted = (0, crypto_1.encryptBinary)(compressed, secretKey, {
276
+ stretchAsciiKey
277
+ });
270
278
  // Capture sequence before building (buildDataPacket advances it)
271
279
  const seq = packetBuilder.getCurrentSequence();
272
280
  // Build v2 packet with header
@@ -349,10 +357,12 @@ function createPipelineV2Client(app, state, metricsApi) {
349
357
  const ackedSeq = packetParser.parseACKPayload(parsed.payload);
350
358
  const now = Date.now();
351
359
  let rttSample = null;
352
- // Estimate RTT from original send timestamp (not retransmit timestamp).
360
+ // Only sample RTT from packets that were NOT retransmitted (Karn's algorithm).
361
+ // When a retransmitted packet is ACKed, the measurement is ambiguous — the ACK
362
+ // could be for the original or the retransmit — so we skip it entirely.
353
363
  const entry = retransmitQueue.get(ackedSeq);
354
- if (entry && (entry.originalTimestamp || entry.timestamp)) {
355
- rttSample = Math.max(0, now - (entry.originalTimestamp || entry.timestamp));
364
+ if (entry && entry.attempts === 0) {
365
+ rttSample = Math.max(0, now - entry.originalTimestamp);
356
366
  }
357
367
  if (rttSample !== null) {
358
368
  metrics.rtt = rttSample;
@@ -376,10 +386,13 @@ function createPipelineV2Client(app, state, metricsApi) {
376
386
  lastAckAt = now;
377
387
  lastAckRinfo = rinfo ? { address: rinfo.address, port: rinfo.port } : lastAckRinfo;
378
388
  // Update congestion control with latest network metrics.
389
+ // Only feed RTT when we have a fresh sample; passing -1 causes the
390
+ // congestion controller's >= 0 guard to skip the RTT EMA update,
391
+ // preventing stale values from being repeatedly folded into the average.
379
392
  // Clamp packetLoss to [0, 1] as a defensive measure against any future
380
393
  // changes to _calculatePacketLoss that could produce out-of-range values.
381
394
  congestionControl.updateMetrics({
382
- rtt: metrics.rtt ?? 0,
395
+ rtt: rttSample ?? -1,
383
396
  packetLoss: Math.min(1, Math.max(0, _calculatePacketLoss()))
384
397
  });
385
398
  app.debug(`ACK received: seq=${ackedSeq}, removed=${removed}, queueDepth=${retransmitQueue.getSize()}, rtt=${metrics.rtt}ms`);