Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
106 changes: 106 additions & 0 deletions src/renderer/lib/reticulum/resolveReticulumChatLxmfDest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
Expand Down Expand Up @@ -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;
Expand Down
58 changes: 57 additions & 1 deletion src/renderer/lib/reticulum/resolveReticulumChatLxmfDest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ReticulumIdentityActivityRow[]>,
): 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,
Expand All @@ -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',
);
Expand Down
26 changes: 26 additions & 0 deletions src/renderer/lib/reticulum/reticulumChatFaceHash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
45 changes: 45 additions & 0 deletions src/renderer/lib/reticulum/reticulumDestinationInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
3 changes: 2 additions & 1 deletion src/renderer/lib/reticulum/reticulumDestinationInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions src/renderer/lib/reticulum/reticulumLinkTimeoutBridgeDedup.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<string>();
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);
});
});
42 changes: 42 additions & 0 deletions src/renderer/lib/reticulum/reticulumLinkTimeoutBridgeDedup.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
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<string>,
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<string>,
destinationHash: string,
): void {
const norm = normalizeLinkTimeoutDestHash(destinationHash);
if (!norm) return;
processed.delete(norm);
}
Loading
Loading