Skip to content

Commit 2d024ac

Browse files
committed
add port conflict resolution
Signed-off-by: Draper <27962761+Drapersniper@users.noreply.github.com>
1 parent 173cc82 commit 2d024ac

6 files changed

Lines changed: 116 additions & 8 deletions

File tree

redbot/cogs/audio/core/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def __init__(self, bot: Red):
6565
self.skip_votes = {}
6666
self.play_lock = {}
6767
self.antispam: Dict[int, Dict[str, AntiSpam]] = defaultdict(lambda: defaultdict(AntiSpam))
68+
self._runtime_external_node = False
6869

6970
self.lavalink_connect_task = None
7071
self._restore_task = None

redbot/cogs/audio/core/abc.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ class MixinMeta(ABC):
6666
_disconnected_players: MutableMapping[int, bool]
6767
global_api_user: MutableMapping[str, Any]
6868

69+
_runtime_external_node: bool
70+
6971
cog_cleaned_up: bool
7072
lavalink_connection_aborted: bool
7173

redbot/cogs/audio/core/commands/audioset.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,6 +1115,8 @@ async def command_audioset_settings(self, ctx: commands.Context):
11151115
lavalink_version=lavalink.__version__,
11161116
use_external_lavalink=_("Enabled")
11171117
if global_data["use_external_lavalink"]
1118+
else _("Enabled (Temporary)")
1119+
if self._runtime_external_node
11181120
else _("Disabled"),
11191121
)
11201122
if (

redbot/cogs/audio/core/tasks/lavalink.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import asyncio
2-
from pathlib import Path
2+
import pathlib
33

44
import lavalink
5+
import yaml
56
from red_commons.logging import getLogger
67

78
from redbot.core import data_manager
@@ -11,7 +12,7 @@
1112
from ..cog_utils import CompositeMetaClass
1213

1314
log = getLogger("red.cogs.Audio.cog.Tasks.lavalink")
14-
_ = Translator("Audio", Path(__file__))
15+
_ = Translator("Audio", pathlib.Path(__file__))
1516

1617

1718
class LavalinkTasks(MixinMeta, metaclass=CompositeMetaClass):
@@ -41,9 +42,8 @@ async def lavalink_attempt_connect(self, timeout: int = 50, manual: bool = False
4142
if self._restore_task:
4243
self._restore_task.cancel()
4344
if self.managed_node_controller is not None:
44-
if not self.managed_node_controller._shutdown:
45-
await self.managed_node_controller.shutdown()
46-
await asyncio.sleep(5)
45+
await self.managed_node_controller.shutdown()
46+
await asyncio.sleep(5)
4747
await lavalink.close(self.bot)
4848
while retry_count < max_retries:
4949
configs = await self.config.all()
@@ -65,6 +65,44 @@ async def lavalink_attempt_connect(self, timeout: int = 50, manual: bool = False
6565
except asyncio.TimeoutError:
6666
if self.managed_node_controller is not None:
6767
await self.managed_node_controller.shutdown()
68+
if self._runtime_external_node is True:
69+
log.warning("Attempting to connect to existing Lavalink Node.")
70+
self.lavalink_connection_aborted = False
71+
matching_processes = (
72+
await self.managed_node_controller.get_lavalink_process(
73+
lazy_match=True
74+
)
75+
)
76+
log.debug(
77+
"Found %s processes with lavalink in the cmdline.",
78+
len(matching_processes),
79+
)
80+
valid_working_dirs = [
81+
cwd
82+
for d in matching_processes
83+
if d.get("name") == "java" and (cwd := d.get("cwd"))
84+
]
85+
log.debug(
86+
"Found %s java processed with a cwd set.", len(valid_working_dirs)
87+
)
88+
for cwd in valid_working_dirs:
89+
config = pathlib.Path(cwd) / "application.yml"
90+
if config.exists() and config.is_file():
91+
log.debug(
92+
"The following config file exists for an unmanaged Lavalink node %s",
93+
config,
94+
)
95+
try:
96+
with config.open(mode="r") as config_data:
97+
data = yaml.safe_load(config_data)
98+
host = data["server"]["address"]
99+
port = data["server"]["port"]
100+
password = data["lavalink"]["server"]["password"]
101+
break
102+
except Exception:
103+
log.verbose("Failed to read contents of %s", config)
104+
continue
105+
break
68106
if self.lavalink_connection_aborted is not True:
69107
log.critical(
70108
"Managed node startup timeout, aborting managed node startup."
@@ -117,9 +155,15 @@ async def lavalink_attempt_connect(self, timeout: int = 50, manual: bool = False
117155
return
118156
except asyncio.TimeoutError:
119157
await lavalink.close(self.bot)
120-
log.warning("Connecting to Lavalink node timed out, retrying...")
121158
retry_count += 1
122-
await asyncio.sleep(1) # prevent busylooping
159+
if self._runtime_external_node is True:
160+
log.warning(
161+
"Attempt to connect to existing Lavalink node failed, aborting future reconnects."
162+
)
163+
self.lavalink_connection_aborted = True
164+
return
165+
log.warning("Connecting to Lavalink node timed out, retrying...")
166+
await asyncio.sleep(1)
123167
except Exception as exc:
124168
log.exception(
125169
"Unhandled exception whilst connecting to Lavalink node, aborting...",

redbot/cogs/audio/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ class ManagedLavalinkStartFailure(ManagedLavalinkNodeException):
3131
"""Exception thrown when a managed Lavalink node fails to start"""
3232

3333

34+
class PortAlreadyInUse(ManagedLavalinkStartFailure):
35+
"""Exception thrown when a managed Lavalink node fails to start due to a port conflict"""
36+
37+
3438
class ManagedLavalinkPreviouslyShutdownException(ManagedLavalinkNodeException):
3539
"""Exception thrown when a managed Lavalink node already has been shutdown"""
3640

redbot/cogs/audio/manager.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
IncorrectProcessFound,
3838
NoProcessFound,
3939
NodeUnhealthy,
40+
PortAlreadyInUse,
4041
)
4142
from .utils import (
4243
change_dict_naming_convention,
@@ -128,6 +129,7 @@ class ServerManager:
128129

129130
def __init__(self, config: Config, cog: "Audio", timeout: Optional[int] = None) -> None:
130131
self.ready: asyncio.Event = asyncio.Event()
132+
self.abort_for_unmanaged: asyncio.Event = asyncio.Event()
131133
self._config = config
132134
self._proc: Optional[asyncio.subprocess.Process] = None # pylint:disable=no-member
133135
self._node_pid: Optional[int] = None
@@ -136,6 +138,7 @@ def __init__(self, config: Config, cog: "Audio", timeout: Optional[int] = None)
136138
self.timeout = timeout
137139
self.cog = cog
138140
self._args = []
141+
self._current_config = {}
139142

140143
@property
141144
def path(self) -> Optional[str]:
@@ -236,6 +239,7 @@ async def process_settings(self):
236239
data["sentry"]["tags"]["rll_version"] = lavalink.__version__
237240
data["sentry"]["tags"]["red_version"] = red_version
238241

242+
self._current_config = data
239243
with open(LAVALINK_APP_YML, "w") as f:
240244
yaml.safe_dump(data, f)
241245

@@ -338,6 +342,14 @@ async def _wait_for_launcher(self) -> None:
338342
log.info("Managed Lavalink node is ready to receive requests.")
339343
break
340344
if _FAILED_TO_START.search(line):
345+
if (
346+
f"Port {self._current_config['server']['port']} was already in use".encode()
347+
in line
348+
):
349+
raise PortAlreadyInUse(
350+
f"Port {self._current_config['server']['port']} already in use. "
351+
f"Managed Lavalink startup aborted."
352+
)
341353
raise ManagedLavalinkStartFailure(
342354
f"Lavalink failed to start: {line.decode().strip()}"
343355
)
@@ -351,6 +363,7 @@ async def _wait_for_launcher(self) -> None:
351363
async def shutdown(self) -> None:
352364
if self.start_monitor_task is not None:
353365
self.start_monitor_task.cancel()
366+
self.abort_for_unmanaged.clear()
354367
await self._partial_shutdown()
355368

356369
async def _partial_shutdown(self) -> None:
@@ -459,8 +472,44 @@ async def maybe_download_jar(self):
459472
if not (LAVALINK_JAR_FILE.exists() and await self._is_up_to_date()):
460473
await self._download_jar()
461474

475+
@staticmethod
476+
async def get_lavalink_process(
477+
*matches: str, cwd: Optional[str] = None, lazy_match: bool = False
478+
):
479+
process_list = []
480+
filter = [cwd] if cwd else []
481+
async for proc in AsyncIter(psutil.process_iter()):
482+
try:
483+
if cwd:
484+
if not (proc.cwd() in filter):
485+
continue
486+
cmdline = proc.cmdline()
487+
if (matches and all(a in cmdline for a in matches)) or (
488+
lazy_match and any("lavalink" in arg.lower() for arg in cmdline)
489+
):
490+
proc_as_dict = proc.as_dict(
491+
attrs=["pid", "name", "create_time", "status", "cmdline", "cwd"]
492+
)
493+
process_list.append(proc_as_dict)
494+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
495+
pass
496+
return process_list
497+
462498
async def wait_until_ready(self, timeout: Optional[float] = None):
463-
await asyncio.wait_for(self.ready.wait(), timeout=timeout or self.timeout)
499+
tasks = [
500+
asyncio.create_task(c) for c in [self.ready.wait(), self.abort_for_unmanaged.wait()]
501+
]
502+
done, pending = await asyncio.wait(
503+
tasks, timeout=timeout or self.timeout, return_when=asyncio.FIRST_COMPLETED
504+
)
505+
for task in pending:
506+
task.cancel()
507+
if done:
508+
done.pop().result()
509+
if self.abort_for_unmanaged.is_set():
510+
raise asyncio.TimeoutError
511+
if not self.ready.is_set():
512+
raise asyncio.TimeoutError
464513

465514
async def start_monitor(self, java_path: str):
466515
retry_count = 0
@@ -546,6 +595,12 @@ async def start_monitor(self, java_path: str):
546595
log.critical(exc)
547596
self.cog.lavalink_connection_aborted = True
548597
return await self.shutdown()
598+
except PortAlreadyInUse as exc:
599+
log.critical(exc)
600+
self.cog.lavalink_connection_aborted = False
601+
self.cog._runtime_external_node = True
602+
self.abort_for_unmanaged.set()
603+
return await self.shutdown()
549604
except ManagedLavalinkNodeException as exc:
550605
delay = backoff.delay()
551606
log.critical(

0 commit comments

Comments
 (0)