Skip to content
Open
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
21 changes: 21 additions & 0 deletions connectd/multiplex.c
Original file line number Diff line number Diff line change
Expand Up @@ -1491,6 +1491,27 @@ static struct io_plan *read_body_from_peer_done(struct io_conn *peer_conn,
return next_read(peer_conn, peer);
}

/* BOLT #1:
*
* The receiving node:
* - upon receiving `error`:
* - if `channel_id` is all zero:
* - MUST fail all channels with the sending node.
* - otherwise:
* - MUST fail the channel referred to by `channel_id`, if that channel is with the
* sending node.
*/
/* channeld abort()s if it ever sees WIRE_ERROR. */
if (type == WIRE_ERROR) {
daemon_conn_send(peer->daemon->master,
take(towire_connectd_peer_spoke(NULL, &peer->id,
peer->counter,
type,
&channel_id,
is_peer_error(tmpctx, decrypted))));
return next_read(peer_conn, peer);
}

/* If we don't find a subdaemon for this, create a new one. */
subd = find_subd(peer, &channel_id);
if (!subd) {
Expand Down
93 changes: 93 additions & 0 deletions tests/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import re
import statistics
import time
import threading
import unittest
import websocket
import signal
Expand Down Expand Up @@ -3218,6 +3219,98 @@ def test_dataloss_protection_no_broadcast(node_factory, bitcoind):
l1.pay(l2, 200000000)


@unittest.skipIf(os.getenv('TEST_DB_PROVIDER', 'sqlite3') != 'sqlite3', "sqlite3-specific DB rollback")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

tests/test_closing.py has three pre-existing tests (test_onchain_reestablish_reply at line 4632, and two more around lines 4661 and 4753) that reconnect a peer to an already-closed channel and then call daemon.wait_for_log("peer_in WIRE_ERROR"). That log line is produced by peer_read() in common/peer_io.c at lines 18-27 via status_peer_io(), which only runs inside the subd (channeld/closingd), when it reads the message off its own socketpair to connectd. But with this fix, connectd/multiplex.c new block runs before find_subd() and returns immediately - the message is never written to subd->outq, so the subd peer_read() never sees it, and "peer_in WIRE_ERROR" is never logged again for a channel-scoped error. The new connectd branch also doesnt call status_peer_io() itself, so this specific log line disappears entirely, not just for one path. these three tests use that log line as their synchronization point before asserting the channel state. If the line never appears, wait_for_log will time out and the tests will fail. Maybe we need to run these three tests against this branch to confirm. If they do fail, either add an equivalent debug log call in the new connectd branch (status_peer_io(LOG_IO_IN, &peer->id, decrypted)) before forwarding, so the same observability is kept, and update the tests to match the new log source, or drop the log-based wait in these tests in favor of the state check that already follows it?

@pytest.mark.openchannel('v1')
@pytest.mark.openchannel('v2')
def test_channel_error_not_lost_while_channeld_exits(node_factory, bitcoind):
"""A channel-scoped error must fail the channel even if channeld is exiting.

BOLT #1: upon receiving a channel-scoped `error`, the node MUST fail
that channel. connectd used to enqueue the error to the existing
channeld; if that subd was already dying (or not reading) the error
was lost and the channel stayed open.
"""
opts = {'dev-no-reconnect': None, 'disable-plugin': 'cln-grpc'}
l1 = node_factory.get_node(may_reconnect=True,
allow_warning=True,
feerates=(7500, 7500, 7500, 7500),
options=opts,
start=False)
# Freezing channeld makes lightningd SIGKILL it later; do not abort.
del l1.daemon.opts['dev-fail-on-subdaemon-fail']
l1.start()
l2 = node_factory.get_node(may_reconnect=True,
feerates=(7500, 7500, 7500, 7500),
broken_log='Cannot broadcast our commitment tx: they have a future one',
options=opts)

l1.rpc.connect(l2.info['id'], 'localhost', l2.port)
l1.fundchannel(l2, 10**6)

dbpath = os.path.join(l2.daemon.lightning_dir, TEST_NETWORK, "lightningd.sqlite3")
orig_db = Path(dbpath).read_bytes()

l1.pay(l2, 200000000)
l1.daemon.wait_for_logs(["peer_in WIRE_REVOKE_AND_ACK"] * 2)
l2.daemon.wait_for_logs(["peer_in WIRE_REVOKE_AND_ACK"] * 2)

# l2 is now behind.
l2.stop()
Path(dbpath).write_bytes(orig_db)
l2.start()

# Hold l2's lightningd so it cannot start channeld (or send error)
# until l1 has sent reestablish and we have frozen that channeld.
# l2's connectd still completes the handshake.
l1.daemon.logsearch_start = len(l1.daemon.logs)
l2_ld = l2.daemon.proc.pid
os.kill(l2_ld, signal.SIGSTOP)
l1_pid = None
connect_err = []

def do_connect():
try:
l1.rpc.connect(l2.info['id'], 'localhost', l2.port)
except Exception as e:
connect_err.append(e)

connector = threading.Thread(target=do_connect)
connector.start()
try:
l1.daemon.wait_for_log('peer_out WIRE_CHANNEL_REESTABLISH')
l1_pid = int(l1.subd_pid('channeld'))
os.kill(l1_pid, signal.SIGSTOP)
finally:
try:
os.kill(l2_ld, signal.SIGCONT)
except ProcessLookupError:
pass

try:
connector.join(TIMEOUT)
if connector.is_alive():
raise TimeoutError('connect did not finish')
if connect_err:
raise connect_err[0]

l2.daemon.wait_for_logs(["Peer permanent failure in CHANNELD_NORMAL:.*Awaiting unilateral close",
'peer_out WIRE_ERROR'])

# BOLT #1: l1 MUST fail the channel referred to by the error.
# This fails if connectd only queued the error to a channeld
# that cannot read it.
wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['state']
== 'AWAITING_UNILATERAL')
l1.daemon.wait_for_log("They sent ERROR.*Awaiting unilateral close")
l1.wait_for_channel_onchain(l2.info['id'])
finally:
if l1_pid is not None:
try:
os.kill(l1_pid, signal.SIGCONT)
except ProcessLookupError:
pass


def test_restart_multi_htlc_rexmit(node_factory, bitcoind, executor):
# l1 disables commit timer once we send first htlc, dies on commit
l1, l2 = node_factory.line_graph(2, opts=[{'disconnect': ['-WIRE_COMMITMENT_SIGNED'],
Expand Down
Loading