Skip to content

android: don't list the self node twice in the peer list - #846

Open
hrueger wants to merge 1 commit into
tailscale:mainfrom
hrueger:fix-duplicate-self-peer-key
Open

android: don't list the self node twice in the peer list#846
hrueger wants to merge 1 commit into
tailscale:mainfrom
hrueger:fix-duplicate-self-peer-key

Conversation

@hrueger

@hrueger hrueger commented Aug 29, 2026

Copy link
Copy Markdown

Hi folks,
I recently started hitting a bug where my app would crash immediately after connecting. With the help of Claude Code, I debugged this and this fix is working for me with my Headscale / Headplane setup. You dont need to merge this directly, just take it as an example fix for the bug. Thanks in advance! 🙏

Claude follows below:

The bug

The app crashes a few seconds after launch, on every launch, and is completely unusable — while the VPN itself keeps working, because IPNService restarts after each crash.

FATAL EXCEPTION: main
Process: com.tailscale.ipn
java.lang.IllegalArgumentException: Key "20" was already used. If you are using
LazyColumn/Row please make sure you provide a unique key for each item.
    at androidx.compose.ui.layout.LayoutNodeSubcompositionsState.subcompose(SubcomposeLayout.kt:453)
    at androidx.compose.foundation.lazy.LazyListMeasuredItemProvider.getAndMeasure(...)
    ...
    at android.view.ViewRootImpl.performDraw(ViewRootImpl.java:5330)

PeerCategorizer.regenerateGroupedPeers appends netmap.SelfNode to netmap.Peers unconditionally:

for (peer in (peers + selfNode)) {

When the control server includes a node in that node's own peer list, the node is listed twice. MainView keys its LazyColumn by Tailcfg.Node.StableID (MainView.kt:647), so Compose rejects the duplicate key with a fatal exception during measure. There are no app frames in the stack — the throw happens inside subcompose — which makes this awkward to diagnose from a crash report alone.

Evidence

Observed against a Headscale control server. Headscale assigns integer StableNodeIDs, which is what makes the duplicated key legible as a node ID; the underlying problem is not specific to Headscale.

A temporary log in regenerateGroupedPeers shows the netmap delivered right after login is clean, and a subsequent one contains the node itself:

selfStableID=20 peerCount=6 selfInPeers=0 peerIDs=[6, 7, 8, 13, 15, 19]
selfStableID=20 peerCount=7 selfInPeers=1 peerIDs=[6, 7, 8, 13, 15, 19, 20]

That second update is what kills the app, and it explains the delay between launch and crash.

The duplicated key tracked the device's own node ID across three separate re-registrations — "18", then "19", then "20" — confirming the duplicate is always the self node rather than a collision between two peers.

The fix

Drop the self node from the peer list before appending the authoritative SelfNode. The client shouldn't take a fatal exception on data a control server is free to send.

Testing

PeerCategorizerTest covers both directions: self present in Peers must be listed exactly once, and the ordinary case where self is absent must still list it. The first test fails on the unfixed code at the uniqueness assertion while the control case passes, so it's a genuine regression test.

  • ./gradlew testDebugUnitTest passes; the new test fails without the fix.
  • ./gradlew ktfmtFormat reformatted nothing.
  • Verified on device: debug build off main, Samsung SM-G781B, Android 13 (API 33). Before the fix, the app crashed within ~3s of every launch. After, it stays up, processes both netmaps including the one that previously killed it, and renders the device list normally.

First hit on 1.102.3 from the Play Store; reproduced and fixed against main at 26e4361 (1.103.159).

Disclosure

This contribution was produced entirely with Claude Code (Opus 5) — the investigation, the root-cause analysis, the fix, the test, and this description. A human directed the work and approved each step, but did not write the code.

Reviewers may want to weigh that accordingly. The claims above are reproducible rather than asserted: the diagnostic output, the three node IDs, and the before/after device behaviour were all captured over adb against a live tailnet, and the test fails without the one-line change.

🤖 Generated with Claude Code

PeerCategorizer.regenerateGroupedPeers appends netmap.SelfNode to
netmap.Peers unconditionally. When the control server includes a node in
its own peer list, that node is then listed twice. MainView keys its
LazyColumn by Tailcfg.Node.StableID, so Compose rejects the duplicate
key with

  java.lang.IllegalArgumentException: Key "20" was already used. If you
  are using LazyColumn/Row please make sure you provide a unique key for
  each item.

thrown on the main thread from LayoutNodeSubcompositionsState.subcompose
during measure. The process dies a few seconds after launch, on every
launch, once the netmap arrives.

Reproduced against a Headscale control server, which assigns integer
StableNodeIDs. The netmap delivered right after login is clean and a
subsequent one includes the node itself:

  selfStableID=20 peerCount=6 selfInPeers=0 peerIDs=[6, 7, 8, 13, 15, 19]
  selfStableID=20 peerCount=7 selfInPeers=1 peerIDs=[6, 7, 8, 13, 15, 19, 20]

The duplicated key tracked the device's own node ID across three
re-registrations (18, 19, 20), confirming the duplicate is always the
self node rather than a peer collision.

Drop the self node from the peer list before appending the authoritative
SelfNode, so the client does not take a fatal exception on data a control
server is free to send.

Signed-off-by: Hannes Rüger <hannes@rueger.events>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 29, 2026 16:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants