@llblab/pi-telegram 0.21.1 → 0.22.1

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/bus.ts CHANGED
@@ -6,7 +6,17 @@
6
6
  */
7
7
 
8
8
  import { randomBytes } from "node:crypto";
9
- import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
9
+ import {
10
+ chmodSync,
11
+ existsSync,
12
+ lstatSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ readlinkSync,
16
+ renameSync,
17
+ symlinkSync,
18
+ unlinkSync,
19
+ } from "node:fs";
10
20
  import {
11
21
  createConnection,
12
22
  createServer,
@@ -14,7 +24,7 @@ import {
14
24
  type Socket,
15
25
  } from "node:net";
16
26
  import { platform as getPlatform } from "node:os";
17
- import { dirname } from "node:path";
27
+ import { basename, dirname, join } from "node:path";
18
28
 
19
29
  import {
20
30
  classifyTelegramBusTransportError,
@@ -26,6 +36,7 @@ import {
26
36
  getTelegramBusTransportRetryPolicy,
27
37
  isTelegramBusPipePath,
28
38
  isRetryableTelegramBusTransportError,
39
+ probeTelegramBusEndpoint,
29
40
  type TelegramBusTransportEventRecorder,
30
41
  type TelegramBusTransportRetryPolicy,
31
42
  } from "./bus-transport.ts";
@@ -41,14 +52,39 @@ export interface TelegramBusProcessRuntime {
41
52
  getFollowerSocketPath: () => string;
42
53
  }
43
54
 
55
+ export function getTelegramProcessBirthIdentity(
56
+ pid: number,
57
+ fallbackGeneration: number | string,
58
+ ): string {
59
+ if (pid > 0) {
60
+ try {
61
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
62
+ const closeParen = stat.lastIndexOf(")");
63
+ const fields = stat
64
+ .slice(closeParen + 2)
65
+ .trim()
66
+ .split(/\s+/u);
67
+ const startTicks = fields[19];
68
+ if (startTicks) return `${pid}:start:${startTicks}`;
69
+ } catch {
70
+ /* non-Linux or inaccessible process metadata */
71
+ }
72
+ }
73
+ return `${pid}:generation:${fallbackGeneration}`;
74
+ }
75
+
44
76
  export function createTelegramBusProcessRuntime(input: {
45
77
  getActiveProfileName: () => string | undefined;
46
78
  pid: number;
47
79
  parentPid: number;
80
+ parentProcessIdentity?: string;
48
81
  createdAtMs: number;
49
82
  }): TelegramBusProcessRuntime {
50
83
  const instanceId = `${input.pid}:${input.createdAtMs}`;
51
- const manualFollowerOwnerId = String(input.parentPid || input.pid);
84
+ const ownerPid = input.parentPid || input.pid;
85
+ const manualFollowerOwnerId =
86
+ input.parentProcessIdentity ??
87
+ getTelegramProcessBirthIdentity(ownerPid, input.createdAtMs);
52
88
  return {
53
89
  instanceId,
54
90
  manualFollowerOwnerId,
@@ -103,6 +139,7 @@ export interface TelegramBusInstanceRegistration {
103
139
  pid?: number;
104
140
  target?: TelegramTarget;
105
141
  busSocketPath?: string;
142
+ registrationGeneration?: string;
106
143
  connectedAtMs: number;
107
144
  }
108
145
 
@@ -112,14 +149,14 @@ export interface TelegramBusFollowerView extends TelegramBusInstanceRegistration
112
149
 
113
150
  export function getTelegramFollowerTargetOwnership(input: {
114
151
  target: TelegramTarget;
115
- followers: TelegramBusFollowerView[];
116
- activeThreadRecords?: Array<{
152
+ followers: readonly TelegramBusFollowerView[];
153
+ activeThreadRecords?: readonly {
117
154
  status?: string;
118
155
  instanceId?: string;
119
156
  profileKey?: string;
120
157
  owner?: { kind?: string };
121
158
  target: TelegramTarget;
122
- }>;
159
+ }[];
123
160
  currentInstanceId?: string;
124
161
  }): { instanceId: string } | undefined {
125
162
  const liveFollower = input.followers.find((follower) => {
@@ -145,12 +182,11 @@ export function getTelegramFollowerTargetOwnership(input: {
145
182
  return record?.instanceId ? { instanceId: record.instanceId } : undefined;
146
183
  }
147
184
 
148
- const TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD =
149
- "__piTelegramAggregateDelivery";
185
+ const TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD = "__piTelegramAggregateDelivery";
150
186
 
151
- export function markTelegramBusAggregateDelivery<T extends Record<string, unknown>>(
152
- body: T,
153
- ): T {
187
+ export function markTelegramBusAggregateDelivery<
188
+ T extends Record<string, unknown>,
189
+ >(body: T): T {
154
190
  return {
155
191
  ...body,
156
192
  [TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD]: true,
@@ -160,11 +196,10 @@ export function markTelegramBusAggregateDelivery<T extends Record<string, unknow
160
196
  export function isTelegramBusAggregateDelivery(body: unknown): boolean {
161
197
  return Boolean(
162
198
  body &&
163
- typeof body === "object" &&
164
- !Array.isArray(body) &&
165
- (body as Record<string, unknown>)[
166
- TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD
167
- ] === true,
199
+ typeof body === "object" &&
200
+ !Array.isArray(body) &&
201
+ (body as Record<string, unknown>)[TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD] ===
202
+ true,
168
203
  );
169
204
  }
170
205
 
@@ -181,6 +216,7 @@ export function isTelegramFollowerApiCallAllowed(input: {
181
216
  follower: TelegramBusFollowerView;
182
217
  method: string;
183
218
  args: unknown[];
219
+ isMessageOwned?: (chatId: number, messageId: number) => boolean;
184
220
  }): boolean {
185
221
  const allowedCallMethods = new Set([
186
222
  "answerCallbackQuery",
@@ -259,14 +295,21 @@ export function isTelegramFollowerApiCallAllowed(input: {
259
295
  return isBotCommandRegistration(input.args[1]);
260
296
  if (apiMethod === "sendChatAction")
261
297
  return isTargetChatScoped(input.args[1]);
262
- if (apiMethod === "sendMessage" && isTelegramBusAggregateDelivery(input.args[1])) {
298
+ if (
299
+ apiMethod === "sendMessage" &&
300
+ isTelegramBusAggregateDelivery(input.args[1])
301
+ ) {
263
302
  const body = input.args[1] as Record<string, unknown>;
264
- return (
265
- body.message_thread_id === undefined && isTargetChatScoped(body)
266
- );
303
+ return body.message_thread_id === undefined && isTargetChatScoped(body);
267
304
  }
268
305
  if (apiMethod === "deleteMessage" || apiMethod === "editMessageText") {
269
- return isTargetMessageScoped(input.args[1]);
306
+ if (!isTargetMessageScoped(input.args[1])) return false;
307
+ const body = input.args[1] as Record<string, unknown>;
308
+ const messageId =
309
+ typeof body.message_id === "number"
310
+ ? body.message_id
311
+ : Number(body.message_id);
312
+ return input.isMessageOwned?.(target!.chatId, messageId) === true;
270
313
  }
271
314
  return allowedCallMethods.has(apiMethod) && isTargetScoped(input.args[1]);
272
315
  }
@@ -281,6 +324,32 @@ export function isTelegramFollowerApiCallAllowed(input: {
281
324
  return false;
282
325
  }
283
326
 
327
+ export interface TelegramFollowerApiCallAuthorizationInput {
328
+ follower: TelegramBusFollowerView;
329
+ method: string;
330
+ args: unknown[];
331
+ }
332
+
333
+ export function createTelegramFollowerApiCallAuthorizer(deps: {
334
+ isMessageOwned(input: {
335
+ chatId: number;
336
+ messageId: number;
337
+ follower: TelegramBusFollowerView;
338
+ }): boolean;
339
+ }): (input: TelegramFollowerApiCallAuthorizationInput) => boolean {
340
+ return (input) =>
341
+ isTelegramFollowerApiCallAllowed({
342
+ ...input,
343
+ isMessageOwned(chatId, messageId) {
344
+ return deps.isMessageOwned({
345
+ chatId,
346
+ messageId,
347
+ follower: input.follower,
348
+ });
349
+ },
350
+ });
351
+ }
352
+
284
353
  export type TelegramBusEnvelope = (
285
354
  | {
286
355
  kind: "follower.register";
@@ -291,12 +360,14 @@ export type TelegramBusEnvelope = (
291
360
  kind: "follower.heartbeat";
292
361
  requestId: string;
293
362
  instanceId: string;
363
+ registrationGeneration?: string;
294
364
  sentAtMs: number;
295
365
  }
296
366
  | {
297
367
  kind: "leader.forwardCallback";
298
368
  requestId: string;
299
369
  recipientInstanceId: string;
370
+ recipientRegistrationGeneration?: string;
300
371
  query: unknown;
301
372
  sentAtMs: number;
302
373
  }
@@ -304,6 +375,7 @@ export type TelegramBusEnvelope = (
304
375
  kind: "leader.forwardReaction";
305
376
  requestId: string;
306
377
  recipientInstanceId: string;
378
+ recipientRegistrationGeneration?: string;
307
379
  reactionUpdate: unknown;
308
380
  sentAtMs: number;
309
381
  }
@@ -311,6 +383,7 @@ export type TelegramBusEnvelope = (
311
383
  kind: "leader.forwardMessage";
312
384
  requestId: string;
313
385
  recipientInstanceId: string;
386
+ recipientRegistrationGeneration?: string;
314
387
  message: unknown;
315
388
  sentAtMs: number;
316
389
  }
@@ -318,6 +391,7 @@ export type TelegramBusEnvelope = (
318
391
  kind: "leader.forwardEditedMessage";
319
392
  requestId: string;
320
393
  recipientInstanceId: string;
394
+ recipientRegistrationGeneration?: string;
321
395
  message: unknown;
322
396
  sentAtMs: number;
323
397
  }
@@ -325,6 +399,7 @@ export type TelegramBusEnvelope = (
325
399
  kind: "leader.replaceFollowerTarget";
326
400
  requestId: string;
327
401
  recipientInstanceId: string;
402
+ recipientRegistrationGeneration?: string;
328
403
  target: TelegramTarget & { threadId: number };
329
404
  oldTarget?: TelegramTarget & { threadId: number };
330
405
  reason: "thread-restore";
@@ -334,6 +409,7 @@ export type TelegramBusEnvelope = (
334
409
  kind: "follower.callApi";
335
410
  requestId: string;
336
411
  instanceId: string;
412
+ registrationGeneration?: string;
337
413
  method: string;
338
414
  args: unknown[];
339
415
  sentAtMs: number;
@@ -344,6 +420,10 @@ export type TelegramBusEnvelope = (
344
420
  ok: boolean;
345
421
  message?: string;
346
422
  result?: unknown;
423
+ error?: {
424
+ code: "commit-unknown" | "request-id-collision" | "ledger-overloaded";
425
+ method?: string;
426
+ };
347
427
  }
348
428
  ) & { auth?: string };
349
429
 
@@ -354,6 +434,16 @@ export function createTelegramBusRequestId(input: {
354
434
  return `${input.instanceId}:${input.sequence}`;
355
435
  }
356
436
 
437
+ export function createTelegramBusRequestIdFactory(
438
+ instanceId: string,
439
+ ): () => string {
440
+ let sequence = 0;
441
+ return () => {
442
+ sequence += 1;
443
+ return createTelegramBusRequestId({ instanceId, sequence });
444
+ };
445
+ }
446
+
357
447
  export function encodeTelegramBusEnvelope(
358
448
  envelope: TelegramBusEnvelope,
359
449
  ): string {
@@ -441,6 +531,13 @@ export interface TelegramBusLocalServerDeps {
441
531
  ) =>
442
532
  Promise<TelegramBusEnvelope | undefined> | TelegramBusEnvelope | undefined;
443
533
  recordTransportEvent?: TelegramBusTransportEventRecorder;
534
+ beforeEndpointPublication?: () => Promise<void> | void;
535
+ commitEndpointPublication?: (commit: () => void) => boolean;
536
+ requestLedgerMaxEntries?: number;
537
+ shouldDropResponse?: (
538
+ request: TelegramBusEnvelope,
539
+ response: TelegramBusEnvelope,
540
+ ) => boolean;
444
541
  }
445
542
 
446
543
  export interface TelegramBusLocalClientOptions {
@@ -469,22 +566,22 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
469
566
  ): {
470
567
  forwardCallback: (input: {
471
568
  query: TCallbackQuery;
472
- ownership: { instanceId: string };
569
+ ownership: { instanceId: string; ownerGeneration?: string };
473
570
  ctx: TContext;
474
571
  }) => Promise<boolean>;
475
572
  forwardReaction: (input: {
476
573
  reactionUpdate: TReactionUpdate;
477
- ownership: { instanceId: string };
574
+ ownership: { instanceId: string; ownerGeneration?: string };
478
575
  ctx: TContext;
479
576
  }) => Promise<boolean>;
480
577
  forwardMessage: (input: {
481
578
  message: TMessage;
482
- ownership: { instanceId: string };
579
+ ownership: { instanceId: string; ownerGeneration?: string };
483
580
  ctx: TContext;
484
581
  }) => Promise<boolean>;
485
582
  forwardEditedMessage: (input: {
486
583
  message: TMessage;
487
- ownership: { instanceId: string };
584
+ ownership: { instanceId: string; ownerGeneration?: string };
488
585
  ctx: TContext;
489
586
  }) => Promise<boolean>;
490
587
  } {
@@ -509,6 +606,9 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
509
606
  kind: "leader.forwardCallback",
510
607
  requestId: deps.createRequestId(),
511
608
  recipientInstanceId: ownership.instanceId,
609
+ ...(ownership.ownerGeneration
610
+ ? { recipientRegistrationGeneration: ownership.ownerGeneration }
611
+ : {}),
512
612
  query,
513
613
  sentAtMs: getNowMs(),
514
614
  }),
@@ -517,6 +617,9 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
517
617
  kind: "leader.forwardReaction",
518
618
  requestId: deps.createRequestId(),
519
619
  recipientInstanceId: ownership.instanceId,
620
+ ...(ownership.ownerGeneration
621
+ ? { recipientRegistrationGeneration: ownership.ownerGeneration }
622
+ : {}),
520
623
  reactionUpdate,
521
624
  sentAtMs: getNowMs(),
522
625
  }),
@@ -525,6 +628,9 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
525
628
  kind: "leader.forwardMessage",
526
629
  requestId: deps.createRequestId(),
527
630
  recipientInstanceId: ownership.instanceId,
631
+ ...(ownership.ownerGeneration
632
+ ? { recipientRegistrationGeneration: ownership.ownerGeneration }
633
+ : {}),
528
634
  message,
529
635
  sentAtMs: getNowMs(),
530
636
  }),
@@ -533,6 +639,9 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
533
639
  kind: "leader.forwardEditedMessage",
534
640
  requestId: deps.createRequestId(),
535
641
  recipientInstanceId: ownership.instanceId,
642
+ ...(ownership.ownerGeneration
643
+ ? { recipientRegistrationGeneration: ownership.ownerGeneration }
644
+ : {}),
536
645
  message,
537
646
  sentAtMs: getNowMs(),
538
647
  }),
@@ -579,6 +688,11 @@ export function createTelegramBusFollowerTargetController(
579
688
  kind: "leader.replaceFollowerTarget",
580
689
  requestId: deps.createRequestId(),
581
690
  recipientInstanceId: follower.instanceId,
691
+ ...(follower.registrationGeneration
692
+ ? {
693
+ recipientRegistrationGeneration: follower.registrationGeneration,
694
+ }
695
+ : {}),
582
696
  target,
583
697
  ...(oldTarget ? { oldTarget } : {}),
584
698
  reason,
@@ -648,8 +762,77 @@ export function createUnauthorizedBusAck(
648
762
  export function createTelegramBusLocalServer(
649
763
  deps: TelegramBusLocalServerDeps,
650
764
  ): TelegramBusLocalServer {
765
+ type LedgerEntry = {
766
+ fingerprint: string;
767
+ settled: boolean;
768
+ result: Promise<TelegramBusEnvelope | undefined>;
769
+ };
770
+ const requestLedger = new Map<string, LedgerEntry>();
771
+ const requestLedgerMaxEntries = Math.max(
772
+ 1,
773
+ deps.requestLedgerMaxEntries ?? 4096,
774
+ );
775
+ const getRequestLedgerKey = (envelope: TelegramBusEnvelope): string => {
776
+ const identity =
777
+ envelope.kind === "follower.register"
778
+ ? envelope.registration.instanceId
779
+ : "instanceId" in envelope
780
+ ? envelope.instanceId
781
+ : "recipientInstanceId" in envelope
782
+ ? envelope.recipientInstanceId
783
+ : "ack";
784
+ return `${envelope.auth ?? ""}:${identity}:${envelope.requestId}`;
785
+ };
786
+ const handleEnvelopeOnce = (
787
+ envelope: TelegramBusEnvelope,
788
+ ): Promise<TelegramBusEnvelope | undefined> => {
789
+ const key = getRequestLedgerKey(envelope);
790
+ const fingerprint = JSON.stringify(envelope);
791
+ const existing = requestLedger.get(key);
792
+ if (existing) {
793
+ if (existing.fingerprint === fingerprint) return existing.result;
794
+ return Promise.resolve({
795
+ kind: "bus.ack",
796
+ requestId: envelope.requestId,
797
+ ok: false,
798
+ message: "Telegram bus request id was reused with a different payload.",
799
+ error: { code: "request-id-collision" },
800
+ });
801
+ }
802
+ if (requestLedger.size >= requestLedgerMaxEntries) {
803
+ const settledKey = Array.from(requestLedger.entries()).find(
804
+ ([, entry]) => entry.settled,
805
+ )?.[0];
806
+ if (settledKey) requestLedger.delete(settledKey);
807
+ }
808
+ if (requestLedger.size >= requestLedgerMaxEntries) {
809
+ return Promise.resolve({
810
+ kind: "bus.ack",
811
+ requestId: envelope.requestId,
812
+ ok: false,
813
+ message: "Telegram bus request ledger is full.",
814
+ error: { code: "ledger-overloaded" },
815
+ });
816
+ }
817
+ const entry: LedgerEntry = {
818
+ fingerprint,
819
+ settled: false,
820
+ result: Promise.resolve().then(() => deps.handleEnvelope(envelope)),
821
+ };
822
+ requestLedger.set(key, entry);
823
+ void entry.result.then(
824
+ () => {
825
+ entry.settled = true;
826
+ },
827
+ () => {
828
+ entry.settled = true;
829
+ },
830
+ );
831
+ return entry.result;
832
+ };
651
833
  let server: Server | undefined;
652
834
  let activeSocketPath: string | undefined;
835
+ let activeListenPath: string | undefined;
653
836
  let endpointRecovery: Promise<boolean> | undefined;
654
837
  let stopGeneration = 0;
655
838
  const sockets = new Set<Socket>();
@@ -661,8 +844,13 @@ export function createTelegramBusLocalServer(
661
844
  start: async () => {
662
845
  if (server) return;
663
846
  const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
664
- activeSocketPath = socketPath;
665
847
  const usesWindowsPipe = isTelegramBusPipePath(socketPath);
848
+ const endpointGeneration = randomBytes(8).toString("hex");
849
+ const listenPath = usesWindowsPipe
850
+ ? socketPath
851
+ : join(dirname(socketPath), `.pi-telegram-${endpointGeneration}.sock`);
852
+ activeSocketPath = socketPath;
853
+ activeListenPath = listenPath;
666
854
  deps.recordTransportEvent?.(
667
855
  "server-start",
668
856
  getTelegramBusEndpointDiagnostics(socketPath),
@@ -671,7 +859,28 @@ export function createTelegramBusLocalServer(
671
859
  const socketDir = dirname(socketPath);
672
860
  mkdirSync(socketDir, { recursive: true, mode: 0o700 });
673
861
  chmodSync(socketDir, 0o700);
674
- if (existsSync(socketPath)) unlinkSync(socketPath);
862
+ if (existsSync(listenPath)) unlinkSync(listenPath);
863
+ const legacyDeadlineMs = Date.now() + 2000;
864
+ while (true) {
865
+ let isLegacySocket = false;
866
+ try {
867
+ isLegacySocket = lstatSync(socketPath).isSocket();
868
+ } catch {
869
+ /* endpoint does not exist */
870
+ }
871
+ if (!isLegacySocket) break;
872
+ const probe = await probeTelegramBusEndpoint({
873
+ endpoint: socketPath,
874
+ timeoutMs: 50,
875
+ });
876
+ if (!probe.reachable) break;
877
+ if (Date.now() >= legacyDeadlineMs) {
878
+ throw new Error(
879
+ `Timed out waiting for legacy Telegram bus endpoint: ${socketPath}`,
880
+ );
881
+ }
882
+ await delayTelegramBusTransportRetry(25);
883
+ }
675
884
  }
676
885
  server = createServer((socket) => {
677
886
  sockets.add(socket);
@@ -685,9 +894,10 @@ export function createTelegramBusLocalServer(
685
894
  void handleTelegramBusSocketLine(
686
895
  line,
687
896
  socket,
688
- deps.handleEnvelope,
897
+ handleEnvelopeOnce,
689
898
  deps.recordTransportEvent,
690
899
  socketPath,
900
+ deps.shouldDropResponse,
691
901
  );
692
902
  }
693
903
  });
@@ -703,7 +913,7 @@ export function createTelegramBusLocalServer(
703
913
  try {
704
914
  await new Promise<void>((resolve, reject) => {
705
915
  server?.once("error", reject);
706
- server?.listen(socketPath, resolve);
916
+ server?.listen(listenPath, resolve);
707
917
  });
708
918
  deps.recordTransportEvent?.(
709
919
  "server-started",
@@ -712,32 +922,76 @@ export function createTelegramBusLocalServer(
712
922
  } catch (error) {
713
923
  server = undefined;
714
924
  activeSocketPath = undefined;
925
+ activeListenPath = undefined;
715
926
  deps.recordTransportEvent?.("server-start-failed", {
716
927
  ...getTelegramBusEndpointDiagnostics(socketPath),
717
928
  ...classifyTelegramBusTransportError(error),
718
929
  });
719
930
  throw error;
720
931
  }
721
- if (!usesWindowsPipe) chmodSync(socketPath, 0o600);
932
+ if (!usesWindowsPipe) {
933
+ chmodSync(listenPath, 0o600);
934
+ const linkPath = `${socketPath}.link.${endpointGeneration}`;
935
+ try {
936
+ symlinkSync(basename(listenPath), linkPath);
937
+ await deps.beforeEndpointPublication?.();
938
+ const committed = deps.commitEndpointPublication
939
+ ? deps.commitEndpointPublication(() =>
940
+ renameSync(linkPath, socketPath),
941
+ )
942
+ : (renameSync(linkPath, socketPath), true);
943
+ if (!committed) {
944
+ throw new Error(
945
+ "Telegram bus endpoint publication lost transport ownership.",
946
+ );
947
+ }
948
+ } catch (error) {
949
+ try {
950
+ if (lstatSync(linkPath).isSymbolicLink()) unlinkSync(linkPath);
951
+ } catch {
952
+ /* no unpublished link to remove */
953
+ }
954
+ const failedServer = server;
955
+ server = undefined;
956
+ activeSocketPath = undefined;
957
+ activeListenPath = undefined;
958
+ if (failedServer) {
959
+ await new Promise<void>((resolve) =>
960
+ failedServer.close(() => resolve()),
961
+ );
962
+ }
963
+ throw error;
964
+ }
965
+ }
722
966
  },
723
967
  stop: async () => {
724
968
  stopGeneration += 1;
969
+ requestLedger.clear();
725
970
  const activeServer = server;
726
971
  const socketPath = activeSocketPath;
972
+ const listenPath = activeListenPath;
727
973
  server = undefined;
728
974
  activeSocketPath = undefined;
975
+ activeListenPath = undefined;
729
976
  for (const socket of sockets) closeSocket(socket);
730
977
  if (activeServer) {
731
978
  await new Promise<void>((resolve) =>
732
979
  activeServer.close(() => resolve()),
733
980
  );
734
981
  }
735
- if (
736
- socketPath &&
737
- !isTelegramBusPipePath(socketPath) &&
738
- existsSync(socketPath)
739
- ) {
740
- unlinkSync(socketPath);
982
+ if (socketPath && listenPath && !isTelegramBusPipePath(socketPath)) {
983
+ try {
984
+ if (
985
+ lstatSync(socketPath).isSymbolicLink() &&
986
+ readlinkSync(socketPath) === basename(listenPath)
987
+ ) {
988
+ // Leave the generation link in place. Node removes only the unique
989
+ // listen path on close; a later generation atomically replaces this
990
+ // link, and existsSync() treats the stopped dangling link as missing.
991
+ }
992
+ } catch {
993
+ /* endpoint already moved or removed */
994
+ }
741
995
  }
742
996
  if (socketPath) {
743
997
  deps.recordTransportEvent?.(
@@ -962,6 +1216,10 @@ async function handleTelegramBusSocketLine(
962
1216
  handleEnvelope: TelegramBusLocalServerDeps["handleEnvelope"],
963
1217
  recordTransportEvent: TelegramBusTransportEventRecorder | undefined,
964
1218
  socketPath: string,
1219
+ shouldDropResponse?: (
1220
+ request: TelegramBusEnvelope,
1221
+ response: TelegramBusEnvelope,
1222
+ ) => boolean,
965
1223
  ): Promise<void> {
966
1224
  const envelope = parseTelegramBusEnvelope(line);
967
1225
  if (!envelope) {
@@ -981,7 +1239,9 @@ async function handleTelegramBusSocketLine(
981
1239
  }
982
1240
  try {
983
1241
  const response = await handleEnvelope(envelope);
984
- if (response) socket.write(encodeTelegramBusEnvelope(response));
1242
+ if (response && !shouldDropResponse?.(envelope, response)) {
1243
+ socket.write(encodeTelegramBusEnvelope(response));
1244
+ }
985
1245
  } catch (error) {
986
1246
  recordTransportEvent?.("server-handler-failed", {
987
1247
  ...getTelegramBusEndpointDiagnostics(socketPath),
@@ -1019,6 +1279,9 @@ function parseHeartbeatEnvelope(
1019
1279
  kind: "follower.heartbeat",
1020
1280
  requestId,
1021
1281
  instanceId: value.instanceId,
1282
+ ...(typeof value.registrationGeneration === "string"
1283
+ ? { registrationGeneration: value.registrationGeneration }
1284
+ : {}),
1022
1285
  sentAtMs: value.sentAtMs,
1023
1286
  }
1024
1287
  : undefined;
@@ -1034,6 +1297,12 @@ function parseForwardCallbackEnvelope(
1034
1297
  kind: "leader.forwardCallback",
1035
1298
  requestId,
1036
1299
  recipientInstanceId: value.recipientInstanceId,
1300
+ ...(typeof value.recipientRegistrationGeneration === "string"
1301
+ ? {
1302
+ recipientRegistrationGeneration:
1303
+ value.recipientRegistrationGeneration,
1304
+ }
1305
+ : {}),
1037
1306
  query: value.query,
1038
1307
  sentAtMs: value.sentAtMs,
1039
1308
  }
@@ -1050,6 +1319,12 @@ function parseForwardReactionEnvelope(
1050
1319
  kind: "leader.forwardReaction",
1051
1320
  requestId,
1052
1321
  recipientInstanceId: value.recipientInstanceId,
1322
+ ...(typeof value.recipientRegistrationGeneration === "string"
1323
+ ? {
1324
+ recipientRegistrationGeneration:
1325
+ value.recipientRegistrationGeneration,
1326
+ }
1327
+ : {}),
1053
1328
  reactionUpdate: value.reactionUpdate,
1054
1329
  sentAtMs: value.sentAtMs,
1055
1330
  }
@@ -1067,6 +1342,12 @@ function parseForwardMessageEnvelope(
1067
1342
  kind,
1068
1343
  requestId,
1069
1344
  recipientInstanceId: value.recipientInstanceId,
1345
+ ...(typeof value.recipientRegistrationGeneration === "string"
1346
+ ? {
1347
+ recipientRegistrationGeneration:
1348
+ value.recipientRegistrationGeneration,
1349
+ }
1350
+ : {}),
1070
1351
  message: value.message,
1071
1352
  sentAtMs: value.sentAtMs,
1072
1353
  }
@@ -1092,6 +1373,12 @@ function parseReplaceFollowerTargetEnvelope(
1092
1373
  kind: "leader.replaceFollowerTarget",
1093
1374
  requestId,
1094
1375
  recipientInstanceId: value.recipientInstanceId,
1376
+ ...(typeof value.recipientRegistrationGeneration === "string"
1377
+ ? {
1378
+ recipientRegistrationGeneration:
1379
+ value.recipientRegistrationGeneration,
1380
+ }
1381
+ : {}),
1095
1382
  target,
1096
1383
  ...(oldTarget ? { oldTarget } : {}),
1097
1384
  reason: value.reason,
@@ -1111,6 +1398,9 @@ function parseCallApiEnvelope(
1111
1398
  kind: "follower.callApi",
1112
1399
  requestId,
1113
1400
  instanceId: value.instanceId,
1401
+ ...(typeof value.registrationGeneration === "string"
1402
+ ? { registrationGeneration: value.registrationGeneration }
1403
+ : {}),
1114
1404
  method: value.method,
1115
1405
  args: value.args,
1116
1406
  sentAtMs: value.sentAtMs,
@@ -1130,6 +1420,21 @@ function parseAckEnvelope(
1130
1420
  message: typeof value.message === "string" ? value.message : undefined,
1131
1421
  };
1132
1422
  if (Object.hasOwn(value, "result")) envelope.result = value.result;
1423
+ if (isRecord(value.error)) {
1424
+ const code = value.error.code;
1425
+ if (
1426
+ code === "commit-unknown" ||
1427
+ code === "request-id-collision" ||
1428
+ code === "ledger-overloaded"
1429
+ ) {
1430
+ envelope.error = {
1431
+ code,
1432
+ ...(typeof value.error.method === "string"
1433
+ ? { method: value.error.method }
1434
+ : {}),
1435
+ };
1436
+ }
1437
+ }
1133
1438
  return envelope;
1134
1439
  }
1135
1440
 
@@ -1157,6 +1462,9 @@ function parseRegistration(
1157
1462
  if (typeof value.busSocketPath === "string") {
1158
1463
  registration.busSocketPath = value.busSocketPath;
1159
1464
  }
1465
+ if (typeof value.registrationGeneration === "string") {
1466
+ registration.registrationGeneration = value.registrationGeneration;
1467
+ }
1160
1468
  if (target) registration.target = target;
1161
1469
  return registration;
1162
1470
  }