Skip to content

jmcore.nick_tracker

jmcore.nick_tracker

Multi-directory aware nick tracking.

Implements the pattern from JoinMarket reference implementation where a nick is only considered "gone" when ALL directory connections report it as disconnected.

This prevents premature nick leave detection when: - A peer temporarily disconnects from one directory but remains on others - Directory connections are flaky or experiencing network issues - There's a race condition between directory updates

Reference: joinmarket-clientserver/src/jmdaemon/onionmc.py:1078-1103

Attributes

TDirectory = TypeVar('TDirectory') module-attribute

Classes

NickTracker

Bases: Generic[TDirectory]

Tracks nick availability across multiple directory servers.

A nick is considered "active" if it appears on at least one directory. A nick is only marked as "gone" when ALL directories report it as disconnected.

This implements the multi-directory awareness pattern from the reference implementation (onionmc.py lines 1078-1103).

Source code in jmcore/src/jmcore/nick_tracker.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
class NickTracker(Generic[TDirectory]):
    """
    Tracks nick availability across multiple directory servers.

    A nick is considered "active" if it appears on at least one directory.
    A nick is only marked as "gone" when ALL directories report it as disconnected.

    This implements the multi-directory awareness pattern from the reference
    implementation (onionmc.py lines 1078-1103).
    """

    def __init__(self, on_nick_leave: Callable[[str], None] | None = None):
        """
        Initialize the nick tracker.

        Args:
            on_nick_leave: Optional callback when a nick leaves ALL directories
        """
        # active_nicks[nick] = {directory1: True, directory2: True, ...}
        # True = nick is present on this directory, False = gone from this directory
        self.active_nicks: dict[str, dict[TDirectory, bool]] = {}
        self.on_nick_leave = on_nick_leave

    def update_nick(self, nick: str, directory: TDirectory, is_present: bool) -> None:
        """
        Update a nick's presence status on a specific directory.

        Args:
            nick: The nick to update
            directory: The directory reporting the status
            is_present: True if nick is present on this directory, False if gone
        """
        if nick not in self.active_nicks:
            self.active_nicks[nick] = {}

        old_status = self.active_nicks[nick].get(directory)
        self.active_nicks[nick][directory] = is_present

        # Check if this update causes the nick to be completely gone
        if not is_present and old_status is True:
            # Nick just disappeared from this directory
            # Check if it's still present on any other directory
            if not self.is_nick_active(nick):
                logger.info(
                    f"Nick {nick} has left all directories "
                    f"(directories: {list(self.active_nicks[nick].keys())})"
                )
                if self.on_nick_leave:
                    self.on_nick_leave(nick)
                # Clean up the entry
                del self.active_nicks[nick]
        elif is_present and old_status is False:
            logger.debug(
                f"Nick {nick} returned to directory {directory} (was previously marked gone)"
            )

    def mark_nick_present(self, nick: str, directory: TDirectory) -> None:
        """
        Mark a nick as present on a directory.

        Args:
            nick: The nick
            directory: The directory where the nick is present
        """
        self.update_nick(nick, directory, True)

    def mark_nick_gone(self, nick: str, directory: TDirectory) -> None:
        """
        Mark a nick as gone from a directory.

        If this is the last directory where the nick was present,
        triggers the on_nick_leave callback.

        Args:
            nick: The nick
            directory: The directory where the nick left
        """
        self.update_nick(nick, directory, False)

    def is_nick_active(self, nick: str) -> bool:
        """
        Check if a nick is active on at least one directory.

        Args:
            nick: The nick to check

        Returns:
            True if nick is present on at least one directory
        """
        if nick not in self.active_nicks:
            return False
        return any(status for status in self.active_nicks[nick].values())

    def get_active_directories_for_nick(self, nick: str) -> list[TDirectory]:
        """
        Get list of directories where a nick is currently present.

        Args:
            nick: The nick to query

        Returns:
            List of directories where nick is active
        """
        if nick not in self.active_nicks:
            return []
        return [
            directory for directory, is_present in self.active_nicks[nick].items() if is_present
        ]

    def get_all_active_nicks(self) -> set[str]:
        """
        Get all nicks that are active on at least one directory.

        Returns:
            Set of active nicks
        """
        return {nick for nick in self.active_nicks if self.is_nick_active(nick)}

    def remove_directory(self, directory: TDirectory) -> list[str]:
        """
        Remove a directory from tracking (when connection is lost).

        Returns list of nicks that became completely gone after removing this directory.

        Args:
            directory: The directory to remove

        Returns:
            List of nicks that are no longer active after removing this directory
        """
        gone_nicks = []

        for nick in list(self.active_nicks.keys()):
            if directory in self.active_nicks[nick]:
                # Remove this directory from the nick's tracking
                del self.active_nicks[nick][directory]

                # Check if nick is now gone from all directories
                if not self.active_nicks[nick]:
                    # No directories left for this nick
                    logger.info(f"Nick {nick} is gone (last directory {directory} was removed)")
                    gone_nicks.append(nick)
                    if self.on_nick_leave:
                        self.on_nick_leave(nick)
                    del self.active_nicks[nick]
                elif not self.is_nick_active(nick):
                    # Still tracked on some directories but marked as gone on all
                    logger.info(
                        f"Nick {nick} is gone from all remaining directories "
                        f"after removing {directory}"
                    )
                    gone_nicks.append(nick)
                    if self.on_nick_leave:
                        self.on_nick_leave(nick)
                    del self.active_nicks[nick]

        if gone_nicks:
            logger.info(
                f"After removing directory {directory}, {len(gone_nicks)} nicks are gone: "
                f"{gone_nicks}"
            )

        return gone_nicks

    def sync_with_peerlist(self, directory: TDirectory, active_nicks: set[str]) -> None:
        """
        Synchronize nick tracking with a directory's peerlist.

        This is called after fetching a peerlist from a directory to update
        the nick tracking state. Nicks not in the peerlist are marked as gone
        from that directory.

        Args:
            directory: The directory reporting the peerlist
            active_nicks: Set of nicks currently active on this directory
        """
        # First, mark all nicks in the peerlist as present
        for nick in active_nicks:
            self.mark_nick_present(nick, directory)

        # Then, mark nicks we're tracking but not in this peerlist as gone from this directory
        for nick in list(self.active_nicks.keys()):
            if directory in self.active_nicks[nick] and nick not in active_nicks:
                self.mark_nick_gone(nick, directory)

    def __repr__(self) -> str:
        """String representation showing active nicks and their directories."""
        return f"NickTracker(active_nicks={len(self.get_all_active_nicks())})"
Attributes
active_nicks: dict[str, dict[TDirectory, bool]] = {} instance-attribute
on_nick_leave = on_nick_leave instance-attribute
Functions
__init__(on_nick_leave: Callable[[str], None] | None = None)

Initialize the nick tracker.

Args: on_nick_leave: Optional callback when a nick leaves ALL directories

Source code in jmcore/src/jmcore/nick_tracker.py
37
38
39
40
41
42
43
44
45
46
47
def __init__(self, on_nick_leave: Callable[[str], None] | None = None):
    """
    Initialize the nick tracker.

    Args:
        on_nick_leave: Optional callback when a nick leaves ALL directories
    """
    # active_nicks[nick] = {directory1: True, directory2: True, ...}
    # True = nick is present on this directory, False = gone from this directory
    self.active_nicks: dict[str, dict[TDirectory, bool]] = {}
    self.on_nick_leave = on_nick_leave
__repr__() -> str

String representation showing active nicks and their directories.

Source code in jmcore/src/jmcore/nick_tracker.py
211
212
213
def __repr__(self) -> str:
    """String representation showing active nicks and their directories."""
    return f"NickTracker(active_nicks={len(self.get_all_active_nicks())})"
get_active_directories_for_nick(nick: str) -> list[TDirectory]

Get list of directories where a nick is currently present.

Args: nick: The nick to query

Returns: List of directories where nick is active

Source code in jmcore/src/jmcore/nick_tracker.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def get_active_directories_for_nick(self, nick: str) -> list[TDirectory]:
    """
    Get list of directories where a nick is currently present.

    Args:
        nick: The nick to query

    Returns:
        List of directories where nick is active
    """
    if nick not in self.active_nicks:
        return []
    return [
        directory for directory, is_present in self.active_nicks[nick].items() if is_present
    ]
get_all_active_nicks() -> set[str]

Get all nicks that are active on at least one directory.

Returns: Set of active nicks

Source code in jmcore/src/jmcore/nick_tracker.py
135
136
137
138
139
140
141
142
def get_all_active_nicks(self) -> set[str]:
    """
    Get all nicks that are active on at least one directory.

    Returns:
        Set of active nicks
    """
    return {nick for nick in self.active_nicks if self.is_nick_active(nick)}
is_nick_active(nick: str) -> bool

Check if a nick is active on at least one directory.

Args: nick: The nick to check

Returns: True if nick is present on at least one directory

Source code in jmcore/src/jmcore/nick_tracker.py
105
106
107
108
109
110
111
112
113
114
115
116
117
def is_nick_active(self, nick: str) -> bool:
    """
    Check if a nick is active on at least one directory.

    Args:
        nick: The nick to check

    Returns:
        True if nick is present on at least one directory
    """
    if nick not in self.active_nicks:
        return False
    return any(status for status in self.active_nicks[nick].values())
mark_nick_gone(nick: str, directory: TDirectory) -> None

Mark a nick as gone from a directory.

If this is the last directory where the nick was present, triggers the on_nick_leave callback.

Args: nick: The nick directory: The directory where the nick left

Source code in jmcore/src/jmcore/nick_tracker.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def mark_nick_gone(self, nick: str, directory: TDirectory) -> None:
    """
    Mark a nick as gone from a directory.

    If this is the last directory where the nick was present,
    triggers the on_nick_leave callback.

    Args:
        nick: The nick
        directory: The directory where the nick left
    """
    self.update_nick(nick, directory, False)
mark_nick_present(nick: str, directory: TDirectory) -> None

Mark a nick as present on a directory.

Args: nick: The nick directory: The directory where the nick is present

Source code in jmcore/src/jmcore/nick_tracker.py
82
83
84
85
86
87
88
89
90
def mark_nick_present(self, nick: str, directory: TDirectory) -> None:
    """
    Mark a nick as present on a directory.

    Args:
        nick: The nick
        directory: The directory where the nick is present
    """
    self.update_nick(nick, directory, True)
remove_directory(directory: TDirectory) -> list[str]

Remove a directory from tracking (when connection is lost).

Returns list of nicks that became completely gone after removing this directory.

Args: directory: The directory to remove

Returns: List of nicks that are no longer active after removing this directory

Source code in jmcore/src/jmcore/nick_tracker.py
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def remove_directory(self, directory: TDirectory) -> list[str]:
    """
    Remove a directory from tracking (when connection is lost).

    Returns list of nicks that became completely gone after removing this directory.

    Args:
        directory: The directory to remove

    Returns:
        List of nicks that are no longer active after removing this directory
    """
    gone_nicks = []

    for nick in list(self.active_nicks.keys()):
        if directory in self.active_nicks[nick]:
            # Remove this directory from the nick's tracking
            del self.active_nicks[nick][directory]

            # Check if nick is now gone from all directories
            if not self.active_nicks[nick]:
                # No directories left for this nick
                logger.info(f"Nick {nick} is gone (last directory {directory} was removed)")
                gone_nicks.append(nick)
                if self.on_nick_leave:
                    self.on_nick_leave(nick)
                del self.active_nicks[nick]
            elif not self.is_nick_active(nick):
                # Still tracked on some directories but marked as gone on all
                logger.info(
                    f"Nick {nick} is gone from all remaining directories "
                    f"after removing {directory}"
                )
                gone_nicks.append(nick)
                if self.on_nick_leave:
                    self.on_nick_leave(nick)
                del self.active_nicks[nick]

    if gone_nicks:
        logger.info(
            f"After removing directory {directory}, {len(gone_nicks)} nicks are gone: "
            f"{gone_nicks}"
        )

    return gone_nicks
sync_with_peerlist(directory: TDirectory, active_nicks: set[str]) -> None

Synchronize nick tracking with a directory's peerlist.

This is called after fetching a peerlist from a directory to update the nick tracking state. Nicks not in the peerlist are marked as gone from that directory.

Args: directory: The directory reporting the peerlist active_nicks: Set of nicks currently active on this directory

Source code in jmcore/src/jmcore/nick_tracker.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def sync_with_peerlist(self, directory: TDirectory, active_nicks: set[str]) -> None:
    """
    Synchronize nick tracking with a directory's peerlist.

    This is called after fetching a peerlist from a directory to update
    the nick tracking state. Nicks not in the peerlist are marked as gone
    from that directory.

    Args:
        directory: The directory reporting the peerlist
        active_nicks: Set of nicks currently active on this directory
    """
    # First, mark all nicks in the peerlist as present
    for nick in active_nicks:
        self.mark_nick_present(nick, directory)

    # Then, mark nicks we're tracking but not in this peerlist as gone from this directory
    for nick in list(self.active_nicks.keys()):
        if directory in self.active_nicks[nick] and nick not in active_nicks:
            self.mark_nick_gone(nick, directory)
update_nick(nick: str, directory: TDirectory, is_present: bool) -> None

Update a nick's presence status on a specific directory.

Args: nick: The nick to update directory: The directory reporting the status is_present: True if nick is present on this directory, False if gone

Source code in jmcore/src/jmcore/nick_tracker.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def update_nick(self, nick: str, directory: TDirectory, is_present: bool) -> None:
    """
    Update a nick's presence status on a specific directory.

    Args:
        nick: The nick to update
        directory: The directory reporting the status
        is_present: True if nick is present on this directory, False if gone
    """
    if nick not in self.active_nicks:
        self.active_nicks[nick] = {}

    old_status = self.active_nicks[nick].get(directory)
    self.active_nicks[nick][directory] = is_present

    # Check if this update causes the nick to be completely gone
    if not is_present and old_status is True:
        # Nick just disappeared from this directory
        # Check if it's still present on any other directory
        if not self.is_nick_active(nick):
            logger.info(
                f"Nick {nick} has left all directories "
                f"(directories: {list(self.active_nicks[nick].keys())})"
            )
            if self.on_nick_leave:
                self.on_nick_leave(nick)
            # Clean up the entry
            del self.active_nicks[nick]
    elif is_present and old_status is False:
        logger.debug(
            f"Nick {nick} returned to directory {directory} (was previously marked gone)"
        )