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
40 changes: 39 additions & 1 deletion contrib/pyln-testing/pyln/testing/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pyln.client import Millisatoshi
from pyln.client import NodeVersion
from pyln.client import Plugin
from pyln.client.plugin import PluginLogHandler

import ephemeral_port_reserve # type: ignore
import tempfile
Expand Down Expand Up @@ -2012,6 +2013,26 @@ def killall(self, expected_successes):
return not unexpected_fail, err_msgs


class _NoPylnInternalsFilter(logging.Filter):
"""Drop log records generated inside the pyln packages themselves.

An inline plugin's Plugin() lives in the test process, so its
PluginLogHandler on the root logger would forward pyln's own machinery
logs into the node's log. wait_for_logs()'s 'Waiting for [pattern]'
announcement embeds the pattern verbatim, lands in the very log being
scanned, and matches itself, silently reducing the wait to a no-op.
Only records from outside pyln (i.e. the plugin author's own logging)
may be forwarded.
"""
PYLN_DIRS = tuple(
os.path.dirname(os.path.abspath(f)) + os.sep
for f in (__file__,
sys.modules[PluginLogHandler.__module__].__file__))

def filter(self, record):
return not os.path.abspath(record.pathname).startswith(self.PYLN_DIRS)


def _inline_plugin(node, setup_fn):
"""Set up an inline plugin serve thread for a not-yet-started node.

Expand All @@ -2031,10 +2052,27 @@ def greet(name, plugin):
"""
sock_path = os.path.join(node.daemon.lightning_dir, TEST_NETWORK, 'inline-plugin.sock')
srv = socket.socket(socket.AF_UNIX)
srv.bind(sock_path)
try:
srv.bind(sock_path)
except OSError as e:
# Linux caps an AF_UNIX path at 108 bytes, and the node dir embeds
# the (possibly long) test name. Bind via a directory fd alias,
# the bind-side analogue of UnixSocket.connect's workaround; the
# socket file still lands at sock_path, where the shim's
# cwd-relative connect expects it.
if e.args[0] != "AF_UNIX path too long" or os.uname()[0] != "Linux":

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.

Ideally we want tests to work on macos as well. There the limit is 104 characters.

I asked the clanker and it thinks you can switch into the directory fist and then bind via file name. Directory switching is process wide and in get_nodes we start nodes in parallel. So need to guard with a lock.

# Module-level lock serializing the (chdir, bind) critical section across
# threads. get_nodes() runs get_node() concurrently via a thread pool, and
# os.chdir() is process-wide state, so two inline-plugin binds racing here
# could each end up binding in the wrong directory.
_inline_plugin_bind_lock = threading.Lock()


def _inline_plugin(node, setup_fn):
    """Set up an inline plugin serve thread for a not-yet-started node.

    Normally called via get_node(inline_plugin=setup_fn).  The plugin's cwd
    (set by lightningd) is node.daemon.lightning_dir/TEST_NETWORK/, which is
    where the shim looks for inline-plugin.sock.
    """
    sockdir = os.path.join(node.daemon.lightning_dir, TEST_NETWORK)
    sockname = 'inline-plugin.sock'
    sock_path = os.path.join(sockdir, sockname)

    # AF_UNIX enforces a hard cap on the bind path (108 bytes on Linux,
    # 104 on macOS/BSD), measured on the string passed to bind(), not the
    # resolved absolute path. CI checkout dirs can exceed that, so chdir
    # into the socket's directory and bind with the short relative name
    # instead. The lock protects the chdir from concurrent get_node() calls
    # (get_nodes() runs these in a thread pool).
    srv = socket.socket(socket.AF_UNIX)
    with _inline_plugin_bind_lock:
        old_cwd = os.getcwd()
        os.chdir(sockdir)
        try:
            srv.bind(sockname)
        finally:
            os.chdir(old_cwd)

    # ... rest of listen/accept/serve-thread setup, using sock_path
    # (the absolute path) wherever the socket needs to be referenced
    # afterward, e.g. for cleanup/unlink.

This is the clanker suggestion but i would consider the NodeFactory own lock around the _inline_plugin call at line 1898.

raise
dirfd = os.open(os.path.dirname(sock_path), os.O_DIRECTORY | os.O_RDONLY)
try:
srv.bind("/proc/self/fd/%d/%s" % (dirfd, os.path.basename(sock_path)))
finally:
os.close(dirfd)
srv.listen(1)

plugin = Plugin(autopatch=False)
for handler in logging.getLogger().handlers:
if isinstance(handler, PluginLogHandler) and handler.plugin is plugin:
handler.addFilter(_NoPylnInternalsFilter())
setup_fn(plugin)

def serve():
Expand Down
33 changes: 33 additions & 0 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import ast
import copy
import json
import logging
import os
import pytest
import random
Expand Down Expand Up @@ -1800,6 +1801,38 @@ def record_lookup(plugin):
assert len(results['sendpay_failure']) == 1


def test_inline_plugin_wait_for_log_no_selfmatch(node_factory):
"""On inline-plugin nodes the test process's logging is forwarded into
the node's log. wait_for_log()'s own 'Waiting for [pattern]'
announcement embeds the pattern, so with test logging at DEBUG it used
to land in the scanned log and match itself, reducing the wait to a
no-op (#9343). A pattern that never appears must genuinely time out.
"""
def setup(plugin):
@plugin.method('inline_ping')
def inline_ping(plugin):
logging.info("AUTHOR_LOG_MARKER_9343")
return {'pong': True}

l1 = node_factory.get_node(inline_plugin=setup)

root = logging.getLogger()
old_level = root.level
root.setLevel(logging.DEBUG)
try:
# The plugin author's own logging must still be forwarded...
assert l1.rpc.call('inline_ping') == {'pong': True}
l1.daemon.wait_for_log('AUTHOR_LOG_MARKER_9343')
# ...but pyln's internal announcements must not be, so a pattern
# that never appears genuinely times out instead of matching the
# forwarded 'Waiting for [pattern]' line.
with pytest.raises(TimeoutError):
l1.daemon.wait_for_log('SELFMATCH_SENTINEL_NEVER_LOGGED',
timeout=5)
finally:
root.setLevel(old_level)


def test_rpc_command_hook(node_factory):
"""Test the `rpc_command` hook chain"""
plugin = [
Expand Down
Loading