@tikoci/centrs 0.1.1 → 0.1.2

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.
@@ -24,9 +24,15 @@
24
24
  * client↔server matrix is loopback-testable without a router; the CHR
25
25
  * integration test (`test/integration/btest.test.ts`) is the real-RouterOS gate.
26
26
  *
27
- * Scope: single TCP data connection + UDP. TCP multi-connection
28
- * (`connection-count > 1`) negotiates its session token here but the parallel
29
- * data-stream fan-out is owned by the orchestrator (a follow-up).
27
+ * Scope: UDP, plus TCP single- **and** multi-connection. For `connection-count > 1`
28
+ * the client reads the session token from the primary's OK, opens the extra data
29
+ * connections (each: read the server HELLO, send the 16-byte join
30
+ * `[token:u16 BE][0x02][0 …]`, then bulk data flows — the server sends **no** ack
31
+ * before data), and drives them into one shared `BandwidthCounters` so throughput
32
+ * aggregates. The secondary-join format is grounded byte-for-byte against RouterOS
33
+ * 7.23.1 (captured all directions; the `0x02` marker is direction-independent).
34
+ * Authenticated (EC-SRP5) multi-connection is not yet supported — the post-auth
35
+ * token is not captured — so those sessions stay single-stream.
30
36
  */
31
37
 
32
38
  import { createSocket } from "node:dgram";
@@ -44,6 +50,7 @@ import {
44
50
  type BtestCommandOptions,
45
51
  type BtestDirection,
46
52
  type BtestProtocol,
53
+ type BtestStatus,
47
54
  classifyAuthResponse,
48
55
  clientUdpPort,
49
56
  decodeCommand,
@@ -54,6 +61,7 @@ import {
54
61
  encodeClientHello,
55
62
  encodeCommand,
56
63
  encodeConfirmation,
64
+ encodeSecondaryJoin,
57
65
  encodeServerChallenge,
58
66
  encodeStatus,
59
67
  encodeUdpPacket,
@@ -534,10 +542,6 @@ export function speedFeedbackBps(bytesReceived: number): number {
534
542
  return Math.min(Math.floor((bytesReceived * 8 * 3) / 2), 0xffffffff);
535
543
  }
536
544
 
537
- function isStatusMarker(buf: Uint8Array, i: number): boolean {
538
- return buf[i] === BTEST_STATUS_MSG_TYPE && (buf[i + 1] as number) >= 0x80;
539
- }
540
-
541
545
  // ── Handshake ─────────────────────────────────────────────────────────────────
542
546
 
543
547
  /** What both sides agree on after the control handshake. */
@@ -860,6 +864,12 @@ interface RunContext {
860
864
  serverUdpPort?: number;
861
865
  /** Peer host for UDP (client: server host; server: client host). */
862
866
  udpPeerHost?: string;
867
+ /**
868
+ * Adapt this side's TX rate from peer status frames (client transmit / both).
869
+ * Set so the merged-stream status reader in `tcpRxLoop` and the dedicated
870
+ * `tcpStatusReaderLoop` share one feedback gate.
871
+ */
872
+ adaptTxFromStatus?: boolean;
863
873
  onInterval?: (sample: BtestIntervalSample) => void;
864
874
  }
865
875
 
@@ -884,6 +894,51 @@ function serverDirections(command: BtestCommand): RoleDirections {
884
894
  };
885
895
  }
886
896
 
897
+ /**
898
+ * Open one secondary TCP data connection that joins an existing multi-connection
899
+ * test. Grounded against RouterOS 7.23.1: the server sends the 4-byte HELLO, the
900
+ * client replies with the 16-byte join `[token:u16 BE][0x02][0 …]`
901
+ * ({@link encodeSecondaryJoin}), and bulk data then flows. The server sends **no**
902
+ * acknowledgement before the data — it waits for *all* connections to join before
903
+ * streaming — so the join must not block on a reply read, or the sequential opens
904
+ * would deadlock (the server stalls waiting for the rest while the client stalls
905
+ * waiting for a reply that never comes, and the server eventually drops the
906
+ * connection). The caller drives the returned channel into the primary session's
907
+ * shared counters so throughput aggregates across all streams.
908
+ */
909
+ async function openSecondaryConnection(
910
+ connect: (host: string, port: number) => Promise<BtestControlChannel>,
911
+ host: string,
912
+ port: number,
913
+ token: number,
914
+ ): Promise<BtestControlChannel> {
915
+ const channel = await connect(host, port);
916
+ try {
917
+ await channel.readExact(4); // server HELLO (01 00 00 00)
918
+ await channel.write(encodeSecondaryJoin(token));
919
+ return channel;
920
+ } catch (error) {
921
+ channel.close();
922
+ throw error;
923
+ }
924
+ }
925
+
926
+ /**
927
+ * Run the bulk TX/RX loops for a secondary data connection. No status or report
928
+ * loops — the primary connection owns those; a secondary only moves bulk bytes
929
+ * into the shared counters. TX paces from the shared `counters.txSpeed` the primary
930
+ * adapts from server feedback.
931
+ */
932
+ async function runSecondaryDataLoops(
933
+ ctx: RunContext,
934
+ dirs: RoleDirections,
935
+ ): Promise<void> {
936
+ const tasks: Promise<void>[] = [];
937
+ if (dirs.shouldTx) tasks.push(tcpTxLoop(ctx, false));
938
+ if (dirs.shouldRx) tasks.push(tcpRxLoop(ctx));
939
+ await Promise.all(tasks);
940
+ }
941
+
887
942
  /** Bulk TCP send loop: writes `txSize` zero/random payloads until stopped. */
888
943
  async function tcpTxLoop(ctx: RunContext, sendStatus: boolean): Promise<void> {
889
944
  await sleep(100);
@@ -932,7 +987,92 @@ async function tcpTxLoop(ctx: RunContext, sendStatus: boolean): Promise<void> {
932
987
  }
933
988
  }
934
989
 
935
- /** Bulk TCP receive loop: counts bytes and scans for interleaved status. */
990
+ /**
991
+ * Apply a decoded peer status to the counters: record the peer CPU and, when this
992
+ * side adapts (client transmit / both), retarget TX from the peer's `bytesReceived`
993
+ * speed-feedback. Shared by the dedicated `tcpStatusReaderLoop` (pure status stream)
994
+ * and the merged-stream demux in `tcpRxLoop`.
995
+ */
996
+ function applyStatusFeedback(ctx: RunContext, status: BtestStatus): void {
997
+ const { counters } = ctx;
998
+ counters.remoteCpu = status.cpuLoad;
999
+ if (ctx.adaptTxFromStatus && status.bytesReceived > 0) {
1000
+ counters.txSpeed = speedFeedbackBps(status.bytesReceived);
1001
+ counters.txSpeedChanged = true;
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * Demux the most recent 12-byte status frame embedded in a bulk TCP chunk and apply
1007
+ * it. In `direction=both` the peer interleaves status frames into the same bulk
1008
+ * stream with no length framing, so they are found structurally: a frame is
1009
+ * `[0x07][cpu][0x00][0x00][seq u32 LE][bytesReceived u32 LE]` — the two reserved
1010
+ * bytes are always zero, making `0x07 ?? 00 00` a reliable marker in the default
1011
+ * zero-filled payload, and it matches both client (CPU high-bit set) and RouterOS
1012
+ * server (high-bit clear) frames. A `--random-data` payload can yield a rare false
1013
+ * positive, and a frame split across a read boundary is missed, but TX speed is a
1014
+ * soft pacing target that self-corrects on the next interval's frame. Only the last
1015
+ * frame in the chunk is applied (the freshest feedback); the scan is in place, so it
1016
+ * adds no copy on the RX hot path. The frame bytes stay counted in `rx` (matching
1017
+ * RouterOS, which bills the overhead as stream bytes).
1018
+ */
1019
+ /**
1020
+ * Sanity ceiling for an embedded-status sequence number. Status frames are emitted
1021
+ * once per `statusIntervalMs` (≥ 20 ms). We bound plausibility to a conservative
1022
+ * long-session window: 6 hours at the minimum 20 ms cadence yields
1023
+ * `(6 * 60 * 60 * 1000) / 20 = 1_080_000` frames, so any candidate sequence above
1024
+ * that is treated as random-noise fallout from a false marker match.
1025
+ */
1026
+ const BTEST_STATUS_MIN_INTERVAL_MS = 20;
1027
+ const BTEST_STATUS_MAX_RUN_HOURS = 6;
1028
+ const BTEST_STATUS_SEQ_SANE_MAX = Math.ceil(
1029
+ (BTEST_STATUS_MAX_RUN_HOURS * 60 * 60 * 1000) / BTEST_STATUS_MIN_INTERVAL_MS,
1030
+ );
1031
+
1032
+ /**
1033
+ * Validate a candidate embedded-status window before trusting it. The structural
1034
+ * marker `0x07 ?? 00 00` is only a 24-bit sentinel, so in a `--random-data` bulk
1035
+ * stream it false-matches roughly once per 16 MB (~once/second at 100 Mb/s) — and a
1036
+ * false frame would feed a garbage `bytesReceived` into `applyStatusFeedback`,
1037
+ * rewriting `counters.txSpeed`. Two cheap, payload-independent invariants reject
1038
+ * those: the CPU byte is a 0..100 percentage (decodeStatus *clamps* it, so check the
1039
+ * raw byte), and the status sequence is a small monotonic counter, never a random
1040
+ * 32-bit value (a bounded run can't reach `BTEST_STATUS_SEQ_SANE_MAX`). Together with
1041
+ * the marker this drops the false-apply rate to negligible while leaving real frames
1042
+ * untouched.
1043
+ */
1044
+ function plausibleStatusFrame(frame: Uint8Array): boolean {
1045
+ if (((frame[1] as number) & 0x7f) > 100) return false;
1046
+ const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
1047
+ return view.getUint32(4, true) <= BTEST_STATUS_SEQ_SANE_MAX;
1048
+ }
1049
+
1050
+ export function applyEmbeddedStatus(ctx: RunContext, chunk: Uint8Array): void {
1051
+ if (chunk.length < BTEST_STATUS_MSG_SIZE) return;
1052
+ const limit = chunk.length - BTEST_STATUS_MSG_SIZE;
1053
+ let lastValid: BtestStatus | undefined;
1054
+ let i = 0;
1055
+ while (i <= limit) {
1056
+ const frame = chunk.subarray(i, i + BTEST_STATUS_MSG_SIZE);
1057
+ if (
1058
+ chunk[i] === BTEST_STATUS_MSG_TYPE &&
1059
+ chunk[i + 2] === 0 &&
1060
+ chunk[i + 3] === 0 &&
1061
+ plausibleStatusFrame(frame)
1062
+ ) {
1063
+ // A real status frame's own seq/bytes fields can contain `0x07 ?? 00 00`
1064
+ // (e.g. seq 7), so consume the whole frame before scanning on — never
1065
+ // re-match a matched frame's payload as a second, garbage frame.
1066
+ lastValid = decodeStatus(frame);
1067
+ i += BTEST_STATUS_MSG_SIZE;
1068
+ } else {
1069
+ i += 1;
1070
+ }
1071
+ }
1072
+ if (lastValid) applyStatusFeedback(ctx, lastValid);
1073
+ }
1074
+
1075
+ /** Bulk TCP receive loop: counts bytes and demuxes interleaved status frames. */
936
1076
  async function tcpRxLoop(ctx: RunContext): Promise<void> {
937
1077
  const { counters } = ctx;
938
1078
  while (counters.running) {
@@ -942,14 +1082,7 @@ async function tcpRxLoop(ctx: RunContext): Promise<void> {
942
1082
  break;
943
1083
  }
944
1084
  counters.addRx(chunk.length);
945
- if (chunk.length >= BTEST_STATUS_MSG_SIZE) {
946
- for (let i = 0; i <= chunk.length - BTEST_STATUS_MSG_SIZE; i += 1) {
947
- if (isStatusMarker(chunk, i)) {
948
- counters.remoteCpu = Math.min((chunk[i + 1] as number) & 0x7f, 100);
949
- break;
950
- }
951
- }
952
- }
1085
+ applyEmbeddedStatus(ctx, chunk);
953
1086
  }
954
1087
  }
955
1088
 
@@ -960,12 +1093,7 @@ async function tcpStatusReaderLoop(ctx: RunContext): Promise<void> {
960
1093
  const frame = await readExactOrStop(ctx, BTEST_STATUS_MSG_SIZE);
961
1094
  if (frame === null) break;
962
1095
  if (frame[0] !== BTEST_STATUS_MSG_TYPE) continue;
963
- const status = decodeStatus(frame);
964
- counters.remoteCpu = status.cpuLoad;
965
- if (status.bytesReceived > 0) {
966
- counters.txSpeed = speedFeedbackBps(status.bytesReceived);
967
- counters.txSpeedChanged = true;
968
- }
1096
+ applyStatusFeedback(ctx, decodeStatus(frame));
969
1097
  }
970
1098
  }
971
1099
 
@@ -1024,6 +1152,8 @@ async function udpTxLoop(
1024
1152
  // yields the event loop every iteration (sleep, or setImmediate when
1025
1153
  // unlimited), so a slot's prior send has flushed long before we cycle back to
1026
1154
  // it. The first allocation also runs encodeUdpPacket's size guard (≥ 4 bytes).
1155
+ // Keep 8 slots so each buffer is only reused after multiple pace() yields;
1156
+ // this gives node:dgram ample time to flush queued sends before a slot cycles.
1027
1157
  const RING = 8;
1028
1158
  const ring = Array.from({ length: RING }, (_, i) => {
1029
1159
  const buf =
@@ -1157,6 +1287,8 @@ export interface BtestClientSessionOptions {
1157
1287
  remoteTxSpeed?: number;
1158
1288
  /** UDP per-packet size; TCP uses its bulk default. */
1159
1289
  txSize?: number;
1290
+ /** Parallel TCP data connections (TCP only); encoded in command byte 3. */
1291
+ tcpConnectionCount?: number;
1160
1292
  randomData?: boolean;
1161
1293
  natMode?: boolean;
1162
1294
  durationMs?: number;
@@ -1181,6 +1313,12 @@ export interface BtestRunSummary {
1181
1313
  stopReason: BtestStopReason;
1182
1314
  serverUdpPort?: number;
1183
1315
  clientUdpPort?: number;
1316
+ /**
1317
+ * TCP data connections actually driven (1 primary + joined secondaries). For a
1318
+ * `connection-count > 1` request this is the realized fan-out; it stays 1 when
1319
+ * the server negotiates no session token (e.g. authenticated sessions).
1320
+ */
1321
+ activeConnections: number;
1184
1322
  }
1185
1323
 
1186
1324
  /** Run a full btest **client** session and return its aggregate summary. */
@@ -1200,6 +1338,9 @@ export async function runBtestClientSession(
1200
1338
  ? { randomData: options.randomData }
1201
1339
  : {}),
1202
1340
  ...(options.txSize !== undefined ? { txSize: options.txSize } : {}),
1341
+ ...(options.tcpConnectionCount !== undefined
1342
+ ? { tcpConnectionCount: options.tcpConnectionCount }
1343
+ : {}),
1203
1344
  ...(options.localTxSpeed !== undefined
1204
1345
  ? { localTxSpeed: options.localTxSpeed }
1205
1346
  : {}),
@@ -1222,14 +1363,38 @@ export async function runBtestClientSession(
1222
1363
  counters.txSpeed = options.localTxSpeed ?? 0;
1223
1364
  const dirs = clientDirections(negotiated.command);
1224
1365
 
1366
+ // Reuse the IP node already resolved for the TCP control connection as the UDP
1367
+ // peer address. The client now addresses every datagram explicitly (no
1368
+ // connect()), so passing a hostname here would make node:dgram run a DNS
1369
+ // lookup per send on the UDP hot path; channel.remoteAddress is already an IP.
1370
+ const udpPeerHost = channel.remoteAddress ?? options.host;
1371
+
1225
1372
  let udp: BtestUdpSocket | undefined;
1226
1373
  if (options.protocol === "udp") {
1227
1374
  udp = (options.createUdpSocket ?? createBtestUdpSocket)();
1228
- await udp.bind(negotiated.clientUdpPort as number, "0.0.0.0");
1229
- await udp.connect(negotiated.serverUdpPort as number, options.host);
1230
- if (options.natMode || dirs.shouldRx) {
1231
- // Originate a flow so the server's datagrams can return (NAT/SLIRP).
1232
- udp.send(new Uint8Array(0));
1375
+ try {
1376
+ await udp.bind(negotiated.clientUdpPort as number, "0.0.0.0");
1377
+ // Do NOT connect() the socket: connect() installs a kernel receive filter
1378
+ // that silently drops datagrams whose source port differs from the connected
1379
+ // peer. RouterOS may send UDP data from a different source port than the
1380
+ // negotiated serverUdpPort (separate TX socket), so the filter would discard
1381
+ // all incoming packets and leave rx at 0 bps throughout. Instead, keep the
1382
+ // socket unconnected (like the server side) and always address sends
1383
+ // explicitly via udpTxLoop's target argument.
1384
+ if (options.natMode || dirs.shouldRx) {
1385
+ // Originate a flow so the server's datagrams can return (NAT/SLIRP).
1386
+ udp.send(
1387
+ new Uint8Array(0),
1388
+ negotiated.serverUdpPort as number,
1389
+ udpPeerHost,
1390
+ );
1391
+ }
1392
+ } catch (error) {
1393
+ // bind()/send() can throw before the normal close path runs; release the
1394
+ // socket and the control channel so a UDP bootstrap failure leaks neither.
1395
+ udp.close();
1396
+ channel.close();
1397
+ throw error;
1233
1398
  }
1234
1399
  }
1235
1400
 
@@ -1248,19 +1413,78 @@ export async function runBtestClientSession(
1248
1413
  ...(negotiated.serverUdpPort !== undefined
1249
1414
  ? { serverUdpPort: negotiated.serverUdpPort }
1250
1415
  : {}),
1251
- udpPeerHost: options.host,
1416
+ udpPeerHost,
1252
1417
  ...(options.onInterval ? { onInterval: options.onInterval } : {}),
1253
1418
  };
1254
1419
 
1255
- const stopReason = await driveSession(ctx, dirs, "client", {
1256
- ...(options.durationMs !== undefined
1257
- ? { durationMs: options.durationMs }
1258
- : {}),
1259
- ...(options.signal ? { signal: options.signal } : {}),
1260
- });
1420
+ // TCP multi-connection fan-out: the server negotiated a session token, so open
1421
+ // the requested extra data connections and drive them into the **shared**
1422
+ // counters, concurrently with the primary, so throughput aggregates across all
1423
+ // streams (grounded against RouterOS 7.23.1). A secondary that fails to open
1424
+ // degrades to fewer streams rather than failing the test — the primary already
1425
+ // carries the session. They share `counters.running`, so the primary's
1426
+ // duration/abort stop unblocks them too.
1427
+ const secondaryChannels: BtestControlChannel[] = [];
1428
+ if (
1429
+ options.protocol === "tcp" &&
1430
+ negotiated.sessionToken !== undefined &&
1431
+ (options.tcpConnectionCount ?? 1) > 1
1432
+ ) {
1433
+ // Dial secondaries at the **same peer** the primary control socket resolved
1434
+ // to, not a fresh `options.host` lookup: a hostname that load-balances across
1435
+ // addresses could otherwise land a secondary on a different RouterOS, where
1436
+ // the session token is unknown and the join fails.
1437
+ const secondaryHost = channel.remoteAddress ?? options.host;
1438
+ const extra = (options.tcpConnectionCount as number) - 1;
1439
+ for (let i = 0; i < extra; i += 1) {
1440
+ try {
1441
+ secondaryChannels.push(
1442
+ await openSecondaryConnection(
1443
+ connect,
1444
+ secondaryHost,
1445
+ controlPort,
1446
+ negotiated.sessionToken,
1447
+ ),
1448
+ );
1449
+ } catch {
1450
+ break;
1451
+ }
1452
+ }
1453
+ }
1454
+ const secondaryLoops = secondaryChannels.map((secondaryChannel) =>
1455
+ runSecondaryDataLoops(
1456
+ {
1457
+ channel: secondaryChannel,
1458
+ command: negotiated.command,
1459
+ counters,
1460
+ txSize,
1461
+ txSpeed: options.localTxSpeed ?? 0,
1462
+ randomData: options.randomData ?? false,
1463
+ statusIntervalMs: options.statusIntervalMs ?? BTEST_STATUS_INTERVAL_MS,
1464
+ },
1465
+ dirs,
1466
+ ),
1467
+ );
1261
1468
 
1262
- udp?.close();
1263
- channel.close();
1469
+ // Close every socket even if a loop throws — a rejected Promise.all must not leak
1470
+ // the UDP socket or any open TCP channel.
1471
+ let stopReason: BtestStopReason;
1472
+ try {
1473
+ const [primaryStopReason] = await Promise.all([
1474
+ driveSession(ctx, dirs, "client", {
1475
+ ...(options.durationMs !== undefined
1476
+ ? { durationMs: options.durationMs }
1477
+ : {}),
1478
+ ...(options.signal ? { signal: options.signal } : {}),
1479
+ }),
1480
+ ...secondaryLoops,
1481
+ ]);
1482
+ stopReason = primaryStopReason;
1483
+ } finally {
1484
+ udp?.close();
1485
+ channel.close();
1486
+ for (const secondaryChannel of secondaryChannels) secondaryChannel.close();
1487
+ }
1264
1488
 
1265
1489
  const sum = counters.summary();
1266
1490
  const summary: BtestRunSummary = {
@@ -1273,6 +1497,7 @@ export async function runBtestClientSession(
1273
1497
  intervals: sum.intervals,
1274
1498
  durationMs: Date.now() - start,
1275
1499
  stopReason,
1500
+ activeConnections: 1 + secondaryChannels.length,
1276
1501
  };
1277
1502
  if (negotiated.username !== undefined) summary.username = negotiated.username;
1278
1503
  if (negotiated.serverUdpPort !== undefined)
@@ -1430,20 +1655,28 @@ async function driveSession(
1430
1655
 
1431
1656
  const tasks: Promise<void>[] = [];
1432
1657
  if (ctx.command.protocol === "udp") {
1433
- // The client `connect()`s its socket (sends with no per-packet address); the
1434
- // server is unconnected and `send_to`s the client (so it can receive from a
1435
- // NAT-rewritten source). See the server's UDP setup.
1436
- const target =
1437
- role === "server" &&
1438
- ctx.clientUdpPort !== undefined &&
1439
- ctx.udpPeerHost !== undefined
1440
- ? { port: ctx.clientUdpPort, host: ctx.udpPeerHost }
1441
- : undefined;
1658
+ // Both client and server address sends explicitly (no connected-socket
1659
+ // default): client → serverUdpPort, server → clientUdpPort. An unconnected
1660
+ // socket accepts datagrams from any source, avoiding the BSD receive filter
1661
+ // that connect() installs and that silently drops packets when RouterOS
1662
+ // sends from an unexpected source port.
1663
+ let target: { port: number; host: string } | undefined;
1664
+ if (ctx.udpPeerHost !== undefined) {
1665
+ if (role === "server" && ctx.clientUdpPort !== undefined) {
1666
+ target = { port: ctx.clientUdpPort, host: ctx.udpPeerHost };
1667
+ } else if (role === "client" && ctx.serverUdpPort !== undefined) {
1668
+ target = { port: ctx.serverUdpPort, host: ctx.udpPeerHost };
1669
+ }
1670
+ }
1442
1671
  if (dirs.shouldTx) tasks.push(udpTxLoop(ctx, target));
1443
1672
  if (dirs.shouldRx) tasks.push(udpRxLoop(ctx));
1444
1673
  tasks.push(udpStatusExchange(ctx, dirs));
1445
1674
  } else {
1446
- // TCP: only the receiving server emits status frames; the client never does.
1675
+ // A transmitting client paces its TX from the server's status feedback. For
1676
+ // `transmit` that arrives on the dedicated status stream (tcpStatusReaderLoop);
1677
+ // for `both` it is interleaved into the bulk RX stream (tcpRxLoop's demux).
1678
+ // Either way this gate routes the feedback into counters.txSpeed.
1679
+ ctx.adaptTxFromStatus = role === "client" && dirs.shouldTx;
1447
1680
  if (dirs.shouldTx && dirs.shouldRx) {
1448
1681
  tasks.push(tcpTxLoop(ctx, role === "server"));
1449
1682
  tasks.push(tcpRxLoop(ctx));
@@ -254,14 +254,18 @@ export function sessionTokenFromResponse(buf: Uint8Array): number {
254
254
 
255
255
  /**
256
256
  * The 16-byte "command" a secondary TCP connection sends to join an existing
257
- * session — the token in bytes 0-1 (big-endian), the rest zero. The server
258
- * answers `01 <tokenHi> <tokenLo> 00` and the connection joins without auth.
257
+ * session — the token in bytes 0-1 (big-endian), a constant `0x02` join marker in
258
+ * byte 2, the rest zero. The connection joins without auth; the server sends **no**
259
+ * acknowledgement before it starts streaming bulk data, so the client must not
260
+ * await one. Grounded byte-for-byte against RouterOS 7.23.1 (`12 34 02 00 …`,
261
+ * direction-independent — see the btest-session.ts header).
259
262
  */
260
263
  export function encodeSecondaryJoin(sessionToken: number): Uint8Array {
261
264
  assertField(sessionToken, U16_MAX, "session-token");
262
265
  const buf = new Uint8Array(BTEST_COMMAND_SIZE);
263
266
  buf[0] = (sessionToken >> 8) & 0xff;
264
267
  buf[1] = sessionToken & 0xff;
268
+ buf[2] = 0x02;
265
269
  return buf;
266
270
  }
267
271