What changed, and why it matters
This commit fixes a crash in Electrum's Lightning peer connection handling. Previously, several background tasks could start before the encrypted transport was fully ready, causing one task to try sending data on an uninitialized connection and trigger an AttributeError. The fix moves the 'wait for initialization' step into the main task loop and adds explicit checks so tasks only run after the connection is ready. It is a reliability/robustness bug, not an obvious security vulnerability.
Treat as a normal bug-fix commit. No immediate security response required, but verify that the new initialization ordering prevents similar races and that the timeout/GracefulDisconnect path is covered by tests.
Security signals we found
Race condition between task spawn and transport initialization
AttributeError crash due to use of uninitialized cryptographic state
Missing synchronization before sending network messages
Defensive assertions added to enforce initialization invariant
Evidence from the diff
The patch restructures LNPeer.main_loop() to await self.initialized before spawning _query_gossip, _process_gossip, _send_own_gossip, and _forward_gossip. It removes the per-task wait from _query_gossip() and replaces it with assertions that self.is_initialized() in _send_own_gossip() and _forward_gossip(). The crash occurred because _send_own_gossip() used a fixed 10-second sleep instead of waiting for initialization, so if handshake took longer it called send_node_announcement() on an LNTransport whose session key (sk) had not yet been set.
Changed components
electrum/lnpeer.pyLNPeer.main_loopLNPeer._query_gossipLNPeer._send_own_gossipLNPeer._forward_gossipLNTransportInspect captured patch +8 / −5
diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py
index a30a85b..7424cb4 100644
--- a/electrum/lnpeer.py
+++ b/electrum/lnpeer.py
@@ -523,7 +523,11 @@ class Peer(Logger, EventListener):
@handle_disconnect
async def main_loop(self):
async with self.taskgroup as group:
- await group.spawn(self._message_loop())
+ await group.spawn(self._message_loop()) # initializes connection
+ try:
+ await util.wait_for2(self.initialized, LN_P2P_NETWORK_TIMEOUT)
+ except Exception as e:
+ raise GracefulDisconnect(f"Failed to initialize: {e!r}") from e
await group.spawn(self._query_gossip())
await group.spawn(self._process_gossip())
await group.spawn(self._send_own_gossip())
@@ -563,6 +567,7 @@ class Peer(Logger, EventListener):
async def _send_own_gossip(self):
if self.lnworker == self.lnworker.network.lngossip:
return
+ assert self.is_initialized()
await asyncio.sleep(10)
while True:
public_channels = [chan for chan in self.lnworker.channels.values() if chan.is_public()]
@@ -583,6 +588,7 @@ class Peer(Logger, EventListener):
return False
async def _forward_gossip(self):
+ assert self.is_initialized()
if not self._should_forward_gossip():
return
@@ -632,10 +638,7 @@ class Peer(Logger, EventListener):
return amount_sent
async def _query_gossip(self):
- try:
- await util.wait_for2(self.initialized, LN_P2P_NETWORK_TIMEOUT)
- except Exception as e:
- raise GracefulDisconnect(f"Failed to initialize: {e!r}") from e
+ assert self.is_initialized()
if self.lnworker == self.lnworker.network.lngossip:
if not self.their_features.supports(LnFeatures.GOSSIP_QUERIES_OPT):
raise GracefulDisconnect("remote does not support gossip_queries, which we need")
Why this scored 26/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.