diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 86e56edee..759e03e45 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1535,17 +1535,18 @@ Bond-stale **TX queue full** hints (`txQueueDropsHintBleBondStale`) point at the ### Reticulum: announces / Nomad / RRC work but Chat fails both ways -**Symptoms**: Both mesh-client instances hear announces, Nomad pages and RRC work, probes look reachable, but Chat DMs never arrive either way. Developer bundles show outbound `to_hash` values that are **not** the peer’s Network **LXMF** hash. Diagnostics may list **Direct LXMF link … timed out** against a hash that identity activity marks as `lxst.telephony`. When **MeshChatX** (or another RNS app) runs on one side, the other may briefly show **Delivered** via RF — that Complete is for MeshChatX’s LXMF identity, not mesh-client Chat. +**Symptoms**: Both mesh-client instances hear announces, Nomad pages and RRC work, probes look reachable, but Chat DMs never arrive either way. Developer bundles show outbound `to_hash` values that are **not** the peer’s Network **LXMF** hash. Pasting the peer’s **identity** hash and their **LXMF** hash opens **two** Chat tabs. Diagnostics may list **Direct LXMF link … timed out** against a hash that identity activity marks as `lxst.telephony` (or against the RNS identity hash). When **MeshChatX** (or another RNS app) runs on one side, the other may briefly show **Delivered** via RF — that Complete is for MeshChatX’s LXMF identity, not mesh-client Chat. Peers may appear in the list (announce heard) while Network topology shows **no** RF edge (`hops` null / no path). Prefer **RF** is not the same as disabling TCP hubs. -**Cause**: The RNS path table lists **every** destination aspect. Opening **Peers → Message** (or a stale DM) on an `lxst.telephony` row sends LXMF Chat to a Voice destination. mesh-client remaps Message to the peer’s `lxmf.delivery` hash when identity activity knows it; without an LXMF announce it refuses send. +**Cause**: The RNS path table lists **every** destination aspect. Opening **Peers → Message** (or a stale DM) on an `lxst.telephony` row, or pasting the peer’s **RNS identity** hash, used to send LXMF Chat to a non-`lxmf.delivery` destination. mesh-client remaps identity and telephony to the peer’s `lxmf.delivery` hash when identity activity knows it; without an LXMF announce it refuses send. A peer coming online after the other side’s hourly announce can miss the reverse LXMF path until **Announce now**. With Propagation **Off**, Direct timeout has no PN cascade. A prior link-timeout failure bridge could also leave later Sends stuck on **Sending** for the same dest until a new outbound clears that dedupe. **Fix / retest checklist**: 1. **Fully quit** MeshChatX / other Reticulum apps on both machines during a mesh-client ↔ mesh-client test. 2. On **Network**, confirm each side’s **LXMF** hash (not only the identity hash). Example pair: upstairs `ac978c…` ↔ downstairs `e3359f…`. -3. Open Chat from Peers **Message** (or paste the peer’s 32-character **LXMF** hash). The DM header shows a copyable **LXMF** prefix — it must match Network, not a Voice-only row. -4. If Direct still fails, set Propagation to **Auto** or **Manual** with a usable PN (Propagation **Off** has no cascade after Direct timeout). -5. Export **both** Developer bundles; check `reticulum_messages.to_hash` against `reticulum_identity_activity` (`lxmf.delivery` vs `lxst.telephony`) and `reticulum/lxmf-outbound.log` for Direct Completes / Failed lines. +3. Both sides **Announce now**, then wait until each sees the peer’s **LXMF** row with a path (hops ≥ 0) or Probe succeeds. +4. Open Chat from Peers **Message** (or paste the peer’s 32-character **LXMF** hash — not the identity hash). The DM header shows a copyable **LXMF** prefix — it must match Network, not identity-only or a Voice-only row. +5. If Direct still fails, set Propagation to **Auto** or **Manual** with a usable PN (Propagation **Off** has no cascade after Direct timeout). Prefer RF does not disable TCP — turn TCP hubs off on Connection → Interfaces when testing RF-only. +6. Export **both** Developer bundles; check `reticulum_messages.to_hash` against `reticulum_identity_activity` (`lxmf.delivery` vs identity / `lxst.telephony`) and `reticulum/lxmf-outbound.log` for Direct Completes / Failed lines. Stuck `reticulum-pending-*` / `sending` rows after link timeouts are a client bridge bug (fixed builds clear dest dedupe on each new Send). ### Reticulum DM stuck on Sending (MeshChatX / shared instance) diff --git a/src/renderer/lib/reticulum/resolveReticulumChatLxmfDest.test.ts b/src/renderer/lib/reticulum/resolveReticulumChatLxmfDest.test.ts index 8d8f1feb6..69d365934 100644 --- a/src/renderer/lib/reticulum/resolveReticulumChatLxmfDest.test.ts +++ b/src/renderer/lib/reticulum/resolveReticulumChatLxmfDest.test.ts @@ -117,6 +117,88 @@ describe('resolveReticulumChatLxmfDestination', () => { }); }); + it('remaps a known RNS identity hash to lxmf.delivery', () => { + useReticulumIdentityActivityStore.setState({ + byDestination: new Map([ + [ + LXMF, + [ + { + destination_hash: LXMF, + aspect: LXMF_DELIVERY_ASPECT, + identity_hash: IDENTITY, + last_seen: 150, + }, + ], + ], + ]), + }); + expect(resolveReticulumChatLxmfDestination(IDENTITY)).toEqual({ + status: 'ok', + hash: LXMF, + remapped: true, + }); + }); + + it('returns missing_lxmf for a known identity without lxmf.delivery', () => { + useReticulumIdentityActivityStore.setState({ + byDestination: new Map([ + [ + TELEPHONY, + [ + { + destination_hash: TELEPHONY, + aspect: LXST_TELEPHONY_ASPECT, + identity_hash: IDENTITY, + last_seen: 200, + }, + ], + ], + ]), + }); + expect(resolveReticulumChatLxmfDestination(IDENTITY)).toEqual({ status: 'missing_lxmf' }); + }); + + it('remaps identity via peer.identity_hash when activity dest rows are empty for the paste', () => { + useReticulumIdentityActivityStore.setState({ + byDestination: new Map([ + [ + LXMF, + [ + { + destination_hash: LXMF, + aspect: LXMF_DELIVERY_ASPECT, + identity_hash: IDENTITY, + last_seen: 150, + }, + ], + ], + ]), + }); + useReticulumPeerStore.setState({ + peers: new Map([ + [ + LXMF, + { + destination_hash: LXMF, + display_name: 'Peer', + identity_hash: IDENTITY, + hops: null, + last_seen: 1, + is_contact: false, + }, + ], + ]), + contacts: new Map(), + history: new Map(), + }); + expect(resolveReticulumChatLxmfDestination(IDENTITY)).toEqual({ + status: 'ok', + hash: LXMF, + remapped: true, + }); + }); + it('rejects invalid hashes', () => { expect(resolveReticulumChatLxmfDestination('not-a-hash')).toEqual({ status: 'invalid' }); }); @@ -233,6 +315,30 @@ describe('canonicalizeReticulumChatDmNodeId', () => { }); }); + it('canonicalizes an identity-bound node id to the LXMF fold', () => { + const identityId = reticulumHashToNodeId(IDENTITY) >>> 0; + const lxmfId = reticulumHashToNodeId(LXMF) >>> 0; + registerReticulumDestinationHash(identityId, IDENTITY); + useReticulumIdentityActivityStore.setState({ + byDestination: new Map([ + [ + LXMF, + [ + { + destination_hash: LXMF, + aspect: LXMF_DELIVERY_ASPECT, + identity_hash: IDENTITY, + last_seen: 150, + }, + ], + ], + ]), + }); + expect(canonicalizeReticulumChatDmNodeId(identityId)).toBe(lxmfId); + expect(remapReticulumChatDmTabNodeId(identityId)).toBe(lxmfId); + expect(reticulumChatDmNodeIdsEquivalent(identityId, lxmfId)).toBe(true); + }); + it('canonicalizes a telephony-bound node id to the LXMF fold', () => { const telephonyId = reticulumHashToNodeId(TELEPHONY) >>> 0; const lxmfId = reticulumHashToNodeId(LXMF) >>> 0; diff --git a/src/renderer/lib/reticulum/resolveReticulumChatLxmfDest.ts b/src/renderer/lib/reticulum/resolveReticulumChatLxmfDest.ts index a35729494..bf1d40375 100644 --- a/src/renderer/lib/reticulum/resolveReticulumChatLxmfDest.ts +++ b/src/renderer/lib/reticulum/resolveReticulumChatLxmfDest.ts @@ -78,12 +78,63 @@ function peerIdentityHint(hash: string): string | null { return peer?.identity_hash ? canonicalizeReticulumDestinationHash(peer.identity_hash) : null; } +/** + * True when `hash` is known as an RNS identity (appears as `identity_hash` on activity + * or peer rows), not merely as a destination aspect hash. + */ +export function isKnownRnsIdentityHash( + hash: string, + activityByDestination: ReadonlyMap< + string, + ReticulumIdentityActivityRow[] + > = useReticulumIdentityActivityStore.getState().byDestination, +): boolean { + const id = canonicalizeReticulumDestinationHash(hash); + if (!id) return false; + for (const rows of activityByDestination.values()) { + for (const row of rows) { + const rowId = row.identity_hash + ? canonicalizeReticulumDestinationHash(row.identity_hash) + : null; + if (rowId === id) return true; + } + } + const store = useReticulumPeerStore.getState(); + for (const map of [store.contacts, store.history, store.peers]) { + for (const peer of map.values()) { + const peerId = peer.identity_hash + ? canonicalizeReticulumDestinationHash(peer.identity_hash) + : null; + if (peerId === id) return true; + } + } + return false; +} + +function resolveIdentityHashToLxmf( + identityHash: string, + activityByDestination: ReadonlyMap, +): ResolveReticulumChatLxmfDestResult { + const lxmf = findLxmfDeliveryHashForIdentity(identityHash, activityByDestination); + if (lxmf) { + return { + status: 'ok', + hash: lxmf, + remapped: lxmf !== identityHash, + }; + } + return { status: 'missing_lxmf' }; +} + /** * Resolve a path-table / pasted / registry destination to the peer's LXMF delivery hash. * - * - Already `lxmf.delivery` (or no aspect known) → use as-is. + * - Already `lxmf.delivery` → use as-is. + * - Bare RNS **identity** hash (known via activity/peer `identity_hash`) → remap to that + * identity's `lxmf.delivery`, or `missing_lxmf` when none heard yet. * - `lxst.telephony` / other non-lxmf with known identity → remap to that identity's lxmf.delivery. * - Non-lxmf with no lxmf.delivery heard → `missing_lxmf` (do not send). + * - No aspect known yet (pasted LXMF / path-table before activity lands) → allow as-is. */ export function resolveReticulumChatLxmfDestination( candidateHash: string, @@ -97,6 +148,11 @@ export function resolveReticulumChatLxmfDestination( return { status: 'ok', hash: canonical, remapped: false }; } + // Pasted / opened RNS identity hash — never treat as lxmf.delivery. + if (isKnownRnsIdentityHash(canonical, activityStore.byDestination)) { + return resolveIdentityHashToLxmf(canonical, activityStore.byDestination); + } + const hasNonLxmfAspect = rows.some( (r) => r.aspect !== LXMF_DELIVERY_ASPECT && r.aspect !== 'unknown', ); diff --git a/src/renderer/lib/reticulum/reticulumChatFaceHash.test.ts b/src/renderer/lib/reticulum/reticulumChatFaceHash.test.ts index 743d5aa34..e0efee916 100644 --- a/src/renderer/lib/reticulum/reticulumChatFaceHash.test.ts +++ b/src/renderer/lib/reticulum/reticulumChatFaceHash.test.ts @@ -150,4 +150,30 @@ describe('resolveReticulumDmFaceHash', () => { expect(resolveReticulumDmBoundDestinationHash(telephonyNum, null)).toBe(telephony); expect(resolveReticulumDmBoundDestinationHash(telephonyNum, telephony)).toBe(telephony); }); + + it('remaps a bound RNS identity hash to lxmf.delivery for DM probe/face', () => { + const identity = '0f79468863d76b3ba574baa92606ffcb'; + const lxmf = 'e3359f1314aff4fb6261400a8202149b'; + const identityNum = reticulumHashToNodeId(identity); + reticulumHashForNodeIdMock.mockImplementation((id: number) => + id === identityNum ? identity : null, + ); + useReticulumIdentityActivityStore.setState({ + byDestination: new Map([ + [ + lxmf, + [ + { + destination_hash: lxmf, + aspect: LXMF_DELIVERY_ASPECT, + identity_hash: identity, + last_seen: 2, + }, + ], + ], + ]), + }); + expect(resolveReticulumDmFaceHash(identityNum, identity)).toBe(lxmf); + expect(resolveReticulumDmFaceHash(identityNum, null)).toBe(lxmf); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumDestinationInput.test.ts b/src/renderer/lib/reticulum/reticulumDestinationInput.test.ts index 8c3a143e2..d2e305d87 100644 --- a/src/renderer/lib/reticulum/reticulumDestinationInput.test.ts +++ b/src/renderer/lib/reticulum/reticulumDestinationInput.test.ts @@ -141,4 +141,49 @@ describe('openReticulumDmFromHash', () => { }); expect(() => openReticulumDmFromHash(telephony)).toThrow(ReticulumChatMissingLxmfError); }); + + it('opens identity hash on the LXMF fold when lxmf.delivery is known', () => { + const identity = '0f79468863d76b3ba574baa92606ffcb'; + const lxmf = 'e3359f1314aff4fb6261400a8202149b'; + useReticulumIdentityActivityStore.setState({ + byDestination: new Map([ + [ + lxmf, + [ + { + destination_hash: lxmf, + aspect: 'lxmf.delivery', + identity_hash: identity, + last_seen: 1, + }, + ], + ], + ]), + }); + const fromIdentity = openReticulumDmFromHash(identity); + const fromLxmf = openReticulumDmFromHash(lxmf); + expect(fromIdentity).toBe(fromLxmf); + expect(resolveReticulumDestinationHash(fromIdentity)).toBe(lxmf); + }); + + it('throws ReticulumChatMissingLxmfError when identity has no lxmf.delivery', () => { + const identity = '0f79468863d76b3ba574baa92606ffcb'; + const telephony = 'ab1d53d6923d6983dfb4451e3869b878'; + useReticulumIdentityActivityStore.setState({ + byDestination: new Map([ + [ + telephony, + [ + { + destination_hash: telephony, + aspect: 'lxst.telephony', + identity_hash: identity, + last_seen: 1, + }, + ], + ], + ]), + }); + expect(() => openReticulumDmFromHash(identity)).toThrow(ReticulumChatMissingLxmfError); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumDestinationInput.ts b/src/renderer/lib/reticulum/reticulumDestinationInput.ts index ee3fb0bb7..aa71372c8 100644 --- a/src/renderer/lib/reticulum/reticulumDestinationInput.ts +++ b/src/renderer/lib/reticulum/reticulumDestinationInput.ts @@ -88,7 +88,8 @@ export function parseReticulumLxmfLinkUrl(url: string): string | null { /** * Register the peer's LXMF delivery hash and return its uint32 node id for chat stores. - * Remaps `lxst.telephony` (and other non-lxmf aspects) to that identity's `lxmf.delivery`. + * Remaps RNS identity hashes and `lxst.telephony` (and other non-lxmf aspects) to that + * identity's `lxmf.delivery` when known. */ export function openReticulumDmFromHash(hash: string): number { const normalized = parseReticulumDestinationInput(hash); diff --git a/src/renderer/lib/reticulum/reticulumLinkTimeoutBridgeDedup.test.ts b/src/renderer/lib/reticulum/reticulumLinkTimeoutBridgeDedup.test.ts new file mode 100644 index 000000000..700d06d16 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumLinkTimeoutBridgeDedup.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { + clearLinkTimeoutDestProcessed, + markLinkTimeoutDestProcessed, + normalizeLinkTimeoutDestHash, + shouldSkipLinkTimeoutDest, +} from '@/renderer/lib/reticulum/reticulumLinkTimeoutBridgeDedup'; + +const DEST = 'ac978c7786832dc2edff1d4782541cbe'; + +describe('reticulumLinkTimeoutBridgeDedup', () => { + it('normalizes hex and rejects short hashes for skip/mark', () => { + expect(normalizeLinkTimeoutDestHash(` ${DEST.toUpperCase()} `)).toBe(DEST); + const processed = new Set(); + expect(shouldSkipLinkTimeoutDest(processed, 'abcd')).toBe(true); + expect(markLinkTimeoutDestProcessed(processed, 'abcd')).toBeNull(); + expect(processed.size).toBe(0); + }); + + it('marks and skips until cleared for a new outbound', () => { + const processed = new Set(); + expect(shouldSkipLinkTimeoutDest(processed, DEST)).toBe(false); + expect(markLinkTimeoutDestProcessed(processed, DEST)).toBe(DEST); + expect(shouldSkipLinkTimeoutDest(processed, DEST)).toBe(true); + // Second send to same dest must re-enable the bridge. + clearLinkTimeoutDestProcessed(processed, DEST); + expect(shouldSkipLinkTimeoutDest(processed, DEST)).toBe(false); + expect(markLinkTimeoutDestProcessed(processed, DEST)).toBe(DEST); + expect(shouldSkipLinkTimeoutDest(processed, DEST)).toBe(true); + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumLinkTimeoutBridgeDedup.ts b/src/renderer/lib/reticulum/reticulumLinkTimeoutBridgeDedup.ts new file mode 100644 index 000000000..950740be3 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumLinkTimeoutBridgeDedup.ts @@ -0,0 +1,42 @@ +/** + * Link-timeout failure-bridge dest dedupe. + * + * The runtime must not permanently skip a destination after the first bridge apply — + * a later Send to the same peer would stay stuck on Sending forever. Clear the dest + * when a new outbound starts so a subsequent timeout can fail that attempt. + */ + +export function normalizeLinkTimeoutDestHash(hash: string): string { + return hash.replace(/[^0-9a-f]/gi, '').toLowerCase(); +} + +/** True when this dest was already failed by the bridge and should be skipped. */ +export function shouldSkipLinkTimeoutDest( + processed: ReadonlySet, + destinationHash: string, +): boolean { + const norm = normalizeLinkTimeoutDestHash(destinationHash); + if (norm.length !== 32) return true; + return processed.has(norm); +} + +/** Mark dest as processed; returns normalized hash or null if invalid. */ +export function markLinkTimeoutDestProcessed( + processed: Set, + destinationHash: string, +): string | null { + const norm = normalizeLinkTimeoutDestHash(destinationHash); + if (norm.length !== 32) return null; + processed.add(norm); + return norm; +} + +/** Allow a later link-timeout bridge to fail new Sends to this dest. */ +export function clearLinkTimeoutDestProcessed( + processed: Set, + destinationHash: string, +): void { + const norm = normalizeLinkTimeoutDestHash(destinationHash); + if (!norm) return; + processed.delete(norm); +} diff --git a/src/renderer/lib/reticulum/sendReticulumChatMessage.test.ts b/src/renderer/lib/reticulum/sendReticulumChatMessage.test.ts index ce9137756..1a846cef9 100644 --- a/src/renderer/lib/reticulum/sendReticulumChatMessage.test.ts +++ b/src/renderer/lib/reticulum/sendReticulumChatMessage.test.ts @@ -107,6 +107,43 @@ describe('sendReticulumChatMessage helpers', () => { expect(resolveReticulumChatDestHashDetailed(0x99)).toEqual({ status: 'missing_lxmf' }); }); + it('sendReticulumChatMessage remaps identity-bound destination to lxmf.delivery', () => { + const identity = '0f79468863d76b3ba574baa92606ffcb'; + const lxmf = 'e3359f1314aff4fb6261400a8202149b'; + const identityNode = 0x4688; + useReticulumIdentityActivityStore.setState({ + byDestination: new Map([ + [ + lxmf, + [ + { + destination_hash: lxmf, + aspect: 'lxmf.delivery', + identity_hash: identity, + last_seen: 1, + }, + ], + ], + ]), + }); + registerReticulumDestinationHash(identityNode, identity); + const session = mountSession(); + sendReticulumChatMessage({ + identityId: ID, + text: 'ping', + channelIndex: 0, + destination: identityNode, + onNoPropagationNode: () => {}, + }); + expect(session.sendMessage).toHaveBeenCalledWith( + 'ping', + lxmf, + undefined, + expect.stringMatching(/^reticulum-pending-/), + undefined, + ); + }); + it('buildReticulumReplyFields returns empty when replyTo absent', () => { expect(buildReticulumReplyFields(ID, undefined)).toEqual({}); expect(buildReticulumReplyFields(ID, '')).toEqual({}); diff --git a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts index 822e712de..dcb60fe3a 100644 --- a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts @@ -301,9 +301,16 @@ describe('useReticulumRuntime outbound delivery persistence', () => { expect(SOURCE).toMatch(/if \(!applyBridge\) \{/); expect(SOURCE).toContain('cascade eligible'); expect(SOURCE).toContain('propagation hydrate failed/uncertain'); + expect(SOURCE).toContain('shouldSkipLinkTimeoutDest'); + expect(SOURCE).toContain('markLinkTimeoutDestProcessed'); + expect(SOURCE).toContain('clearLinkTimeoutDestProcessed'); expect(SOURCE).toMatch( - /processedLinkTimeoutDestsRef\.current\.add\(norm\);\s*failReticulumSendingOutboundToDestHash/, + /markLinkTimeoutDestProcessed\(\s*processedLinkTimeoutDestsRef\.current,\s*destinationHash,\s*\)/, ); + expect(SOURCE).toMatch( + /clearLinkTimeoutDestProcessed\(processedLinkTimeoutDestsRef\.current, destination\)/, + ); + expect(SOURCE).toMatch(/failReticulumSendingOutboundToDestHash\(/); }); it('aborts link-timeout bridge after delayed hydrate when generation is stale', () => { diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index dc07faca0..9aa3694e5 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -74,6 +74,11 @@ import { isReticulumIpcSendTimeout, withReticulumIpcSendDeadline, } from '@/renderer/lib/reticulum/reticulumIpcDeadline'; +import { + clearLinkTimeoutDestProcessed, + markLinkTimeoutDestProcessed, + shouldSkipLinkTimeoutDest, +} from '@/renderer/lib/reticulum/reticulumLinkTimeoutBridgeDedup'; import { logReticulumInterfaceStateEvent, logReticulumLocalInterfaceHealthChanges, @@ -1747,8 +1752,12 @@ export function useReticulumRuntime(): ProtocolRuntime { ) { return; } + if ( + shouldSkipLinkTimeoutDest(processedLinkTimeoutDestsRef.current, destinationHash) + ) { + continue; + } const norm = destinationHash.replace(/[^0-9a-f]/gi, '').toLowerCase(); - if (!norm || processedLinkTimeoutDestsRef.current.has(norm)) continue; // PN cascade (remote or local-prop): sidecar owns outcome via WS. if (!applyBridge) { console.debug( @@ -1756,10 +1765,14 @@ export function useReticulumRuntime(): ProtocolRuntime { ); continue; } - processedLinkTimeoutDestsRef.current.add(norm); + const marked = markLinkTimeoutDestProcessed( + processedLinkTimeoutDestsRef.current, + destinationHash, + ); + if (!marked) continue; failReticulumSendingOutboundToDestHash( bridgeIdentityId, - norm, + marked, i18n.t('chatPanel.reticulumSendFailed'), ); } @@ -2245,6 +2258,9 @@ export function useReticulumRuntime(): ProtocolRuntime { typeof to === 'string' ? to : (reticulumHashForNodeId(to) ?? resolveReticulumDestinationHash(to) ?? String(to)); + // New outbound: allow a later link-timeout bridge to fail this attempt + // (do not permanently skip the dest after a prior bridge apply). + clearLinkTimeoutDestProcessed(processedLinkTimeoutDestsRef.current, destination); const body: Record = { destination_hash: destination, text,