olas-operate-middleware 0.10.19__py3-none-any.whl → 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {olas_operate_middleware-0.10.19.dist-info → olas_operate_middleware-0.11.0.dist-info}/METADATA +3 -1
  2. {olas_operate_middleware-0.10.19.dist-info → olas_operate_middleware-0.11.0.dist-info}/RECORD +30 -27
  3. operate/bridge/bridge_manager.py +10 -12
  4. operate/bridge/providers/lifi_provider.py +5 -4
  5. operate/bridge/providers/native_bridge_provider.py +6 -5
  6. operate/bridge/providers/provider.py +22 -87
  7. operate/bridge/providers/relay_provider.py +5 -4
  8. operate/cli.py +446 -168
  9. operate/constants.py +22 -2
  10. operate/keys.py +13 -0
  11. operate/ledger/__init__.py +107 -2
  12. operate/ledger/profiles.py +79 -11
  13. operate/operate_types.py +205 -2
  14. operate/quickstart/run_service.py +6 -10
  15. operate/services/agent_runner.py +5 -3
  16. operate/services/deployment_runner.py +3 -0
  17. operate/services/funding_manager.py +904 -0
  18. operate/services/health_checker.py +4 -4
  19. operate/services/manage.py +183 -310
  20. operate/services/protocol.py +392 -140
  21. operate/services/service.py +81 -5
  22. operate/settings.py +70 -0
  23. operate/utils/__init__.py +0 -29
  24. operate/utils/gnosis.py +79 -24
  25. operate/utils/single_instance.py +226 -0
  26. operate/wallet/master.py +221 -181
  27. operate/wallet/wallet_recovery_manager.py +5 -5
  28. {olas_operate_middleware-0.10.19.dist-info → olas_operate_middleware-0.11.0.dist-info}/WHEEL +0 -0
  29. {olas_operate_middleware-0.10.19.dist-info → olas_operate_middleware-0.11.0.dist-info}/entry_points.txt +0 -0
  30. {olas_operate_middleware-0.10.19.dist-info → olas_operate_middleware-0.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -35,6 +35,7 @@ from json import JSONDecodeError
35
35
  from pathlib import Path
36
36
  from traceback import print_exc
37
37
 
38
+ import requests
38
39
  from aea.configurations.constants import (
39
40
  DEFAULT_LEDGER,
40
41
  LEDGER,
@@ -42,6 +43,7 @@ from aea.configurations.constants import (
42
43
  PRIVATE_KEY_PATH_SCHEMA,
43
44
  SKILL,
44
45
  )
46
+ from aea.helpers.logging import setup_logger
45
47
  from aea.helpers.yaml_utils import yaml_dump, yaml_load, yaml_load_all
46
48
  from aea_cli_ipfs.ipfs_utils import IPFSTool
47
49
  from autonomy.cli.helpers.deployment import run_deployment, stop_deployment
@@ -64,16 +66,21 @@ from autonomy.deploy.generators.kubernetes.base import KubernetesGenerator
64
66
  from docker import from_env
65
67
 
66
68
  from operate.constants import (
69
+ AGENT_FUNDS_STATUS_URL,
67
70
  AGENT_PERSISTENT_STORAGE_ENV_VAR,
68
71
  CONFIG_JSON,
69
72
  DEPLOYMENT_DIR,
70
73
  DEPLOYMENT_JSON,
71
74
  HEALTHCHECK_JSON,
75
+ SERVICE_SAFE_PLACEHOLDER,
76
+ ZERO_ADDRESS,
72
77
  )
73
78
  from operate.keys import KeysManager
79
+ from operate.ledger import get_default_rpc
74
80
  from operate.operate_http.exceptions import NotAllowed
75
81
  from operate.operate_types import (
76
82
  Chain,
83
+ ChainAmounts,
77
84
  ChainConfig,
78
85
  ChainConfigs,
79
86
  DeployedNodes,
@@ -106,6 +113,8 @@ NON_EXISTENT_TOKEN = -1
106
113
 
107
114
  AGENT_TYPE_IDS = {"mech": 37, "optimus": 40, "modius": 40, "trader": 25}
108
115
 
116
+ logger = setup_logger("operate.services.service")
117
+
109
118
 
110
119
  def mkdirs(build_dir: Path) -> None:
111
120
  """Build necessary directories."""
@@ -825,11 +834,12 @@ class Service(LocalResource):
825
834
  )
826
835
  )
827
836
 
828
- ledger_configs = ServiceHelper(path=package_absolute_path).ledger_configs()
829
-
830
837
  chain_configs = {}
831
- for chain, config in service_template["configurations"].items():
832
- ledger_config = ledger_configs[chain]
838
+ for chain_str, config in service_template["configurations"].items():
839
+ chain = Chain(chain_str)
840
+ ledger_config = LedgerConfig(
841
+ rpc=get_default_rpc(Chain(chain_str)), chain=chain
842
+ )
833
843
  ledger_config.rpc = config["rpc"]
834
844
 
835
845
  chain_data = OnChainData(
@@ -839,7 +849,7 @@ class Service(LocalResource):
839
849
  user_params=OnChainUserParams.from_json(config), # type: ignore
840
850
  )
841
851
 
842
- chain_configs[chain] = ChainConfig(
852
+ chain_configs[chain_str] = ChainConfig(
843
853
  ledger_config=ledger_config,
844
854
  chain_data=chain_data,
845
855
  )
@@ -1118,3 +1128,69 @@ class Service(LocalResource):
1118
1128
 
1119
1129
  if updated:
1120
1130
  self.store()
1131
+
1132
+ def get_initial_funding_amounts(self) -> ChainAmounts:
1133
+ """Get funding amounts as a dict structure."""
1134
+ amounts = ChainAmounts()
1135
+
1136
+ for chain_str, chain_config in self.chain_configs.items():
1137
+ fund_requirements = chain_config.chain_data.user_params.fund_requirements
1138
+ service_safe = chain_config.chain_data.multisig
1139
+
1140
+ if service_safe is None or service_safe == ZERO_ADDRESS:
1141
+ service_safe = SERVICE_SAFE_PLACEHOLDER
1142
+
1143
+ chain_amounts = amounts.setdefault(chain_str, {})
1144
+ for asset, req in fund_requirements.items():
1145
+ chain_amounts.setdefault(service_safe, {})[asset] = req.safe
1146
+ for agent_address in self.agent_addresses:
1147
+ chain_amounts.setdefault(agent_address, {})[asset] = req.agent
1148
+
1149
+ return amounts
1150
+
1151
+ def get_funding_requests(self) -> ChainAmounts:
1152
+ """Get funding amounts requested by the agent."""
1153
+ agent_response = {}
1154
+ funding_requests = ChainAmounts()
1155
+
1156
+ if self.deployment.status != DeploymentStatus.DEPLOYED:
1157
+ return funding_requests
1158
+
1159
+ try:
1160
+ resp = requests.get(AGENT_FUNDS_STATUS_URL, timeout=10)
1161
+ resp.raise_for_status()
1162
+ agent_response = resp.json()
1163
+ except Exception as e: # pylint: disable=broad-except
1164
+ logger.warning(
1165
+ f"[FUNDING MANAGER] Cannot read url {AGENT_FUNDS_STATUS_URL}: {e}"
1166
+ )
1167
+
1168
+ for chain_str, addresses in agent_response.items():
1169
+ for address, assets in addresses.items():
1170
+ if chain_str not in self.chain_configs:
1171
+ raise ValueError(
1172
+ f"Service {self.service_config_id} asked funding for an unknown chain {chain_str}."
1173
+ )
1174
+
1175
+ if (
1176
+ address not in self.agent_addresses
1177
+ and address != self.chain_configs[chain_str].chain_data.multisig
1178
+ ):
1179
+ raise ValueError(
1180
+ f"Service {self.service_config_id} asked funding for an unknown address {address} on chain {chain_str}."
1181
+ )
1182
+
1183
+ funding_requests.setdefault(chain_str, {})
1184
+ funding_requests[chain_str].setdefault(address, {})
1185
+ for asset, amounts in assets.items():
1186
+ try:
1187
+ funding_requests[chain_str][address][asset] = int(
1188
+ amounts["deficit"]
1189
+ )
1190
+ except (ValueError, TypeError):
1191
+ logger.warning(
1192
+ f"[FUNDING MANAGER] Invalid funding amount {amounts['deficit']} for asset {asset} on chain {chain_str} for address {address}. Setting to 0."
1193
+ )
1194
+ funding_requests[chain_str][address][asset] = 0
1195
+
1196
+ return funding_requests
operate/settings.py ADDED
@@ -0,0 +1,70 @@
1
+ # -*- coding: utf-8 -*-
2
+ # ------------------------------------------------------------------------------
3
+ #
4
+ # Copyright 2024 Valory AG
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ # ------------------------------------------------------------------------------
19
+ """Settings for operate."""
20
+
21
+ from pathlib import Path
22
+ from typing import Any, Dict, Optional
23
+
24
+ from operate.constants import SETTINGS_JSON
25
+ from operate.ledger.profiles import DEFAULT_EOA_TOPUPS
26
+ from operate.operate_types import ChainAmounts
27
+ from operate.resource import LocalResource
28
+
29
+
30
+ SETTINGS_JSON_VERSION = 1
31
+ DEFAULT_SETTINGS = {
32
+ "version": SETTINGS_JSON_VERSION,
33
+ "eoa_topups": DEFAULT_EOA_TOPUPS,
34
+ }
35
+
36
+
37
+ class Settings(LocalResource):
38
+ """Settings for operate."""
39
+
40
+ _file = SETTINGS_JSON
41
+
42
+ version: int
43
+ eoa_topups: Dict[str, Dict[str, int]]
44
+
45
+ def __init__(self, path: Optional[Path] = None, **kwargs: Any) -> None:
46
+ """Initialize settings."""
47
+ super().__init__(path=path)
48
+ if path is not None and (path / self._file).exists():
49
+ self.load(path)
50
+
51
+ for key, default_value in DEFAULT_SETTINGS.items():
52
+ value = kwargs.get(key, default_value)
53
+ if not hasattr(self, key):
54
+ setattr(self, key, value)
55
+
56
+ if self.version != SETTINGS_JSON_VERSION:
57
+ raise ValueError(
58
+ f"Settings version {self.version} is not supported. Expected version {SETTINGS_JSON_VERSION}."
59
+ )
60
+
61
+ def get_eoa_topups(self, with_safe: bool = False) -> ChainAmounts:
62
+ """Get the EOA topups."""
63
+ return (
64
+ self.eoa_topups
65
+ if with_safe
66
+ else {
67
+ chain: {asset: amount * 2 for asset, amount in asset_amount.items()}
68
+ for chain, asset_amount in self.eoa_topups.items()
69
+ }
70
+ )
operate/utils/__init__.py CHANGED
@@ -19,7 +19,6 @@
19
19
 
20
20
  """Helper utilities."""
21
21
 
22
- import functools
23
22
  import shutil
24
23
  import time
25
24
  import typing as t
@@ -32,34 +31,6 @@ class SingletonMeta(type):
32
31
 
33
32
  _instances: t.Dict[t.Type, t.Any] = {}
34
33
  _lock: Lock = Lock()
35
- _class_locks: t.Dict[t.Type, Lock] = {}
36
-
37
- def __new__(
38
- cls, name: str, bases: t.Tuple[type, ...], dct: t.Dict[str, t.Any]
39
- ) -> t.Type:
40
- """Create a new class with thread-safe methods."""
41
- # Wrap all callable methods (except special methods) with thread safety
42
- for key, value in list(dct.items()):
43
- if callable(value) and not key.startswith("__"):
44
- dct[key] = cls._make_thread_safe(value)
45
-
46
- new_class = super().__new__(cls, name, bases, dct)
47
- cls._class_locks[new_class] = Lock()
48
- return new_class
49
-
50
- @staticmethod
51
- def _make_thread_safe(func: t.Callable) -> t.Callable:
52
- """Wrap a function to make it thread-safe."""
53
-
54
- @functools.wraps(func)
55
- def wrapper(self: t.Any, *args: t.Any, **kwargs: t.Any) -> t.Any:
56
- class_lock = SingletonMeta._class_locks.get(type(self))
57
- if class_lock:
58
- with class_lock:
59
- return func(self, *args, **kwargs)
60
- return func(self, *args, **kwargs)
61
-
62
- return wrapper
63
34
 
64
35
  def __call__(cls, *args: t.Any, **kwargs: t.Any) -> t.Any:
65
36
  """Override the __call__ method to control instance creation."""
operate/utils/gnosis.py CHANGED
@@ -22,6 +22,7 @@
22
22
  import binascii
23
23
  import itertools
24
24
  import secrets
25
+ import time
25
26
  import typing as t
26
27
  from enum import Enum
27
28
 
@@ -39,6 +40,7 @@ from operate.constants import (
39
40
  ON_CHAIN_INTERACT_TIMEOUT,
40
41
  ZERO_ADDRESS,
41
42
  )
43
+ from operate.ledger import get_default_ledger_api
42
44
  from operate.operate_types import Chain
43
45
 
44
46
 
@@ -174,11 +176,7 @@ def create_safe(
174
176
  tx = registry_contracts.gnosis_safe.get_deploy_transaction(
175
177
  ledger_api=ledger_api,
176
178
  deployer_address=crypto.address,
177
- owners=(
178
- [crypto.address]
179
- if backup_owner is None
180
- else [crypto.address, backup_owner]
181
- ),
179
+ owners=([crypto.address]),
182
180
  threshold=1,
183
181
  salt_nonce=salt_nonce,
184
182
  )
@@ -209,7 +207,31 @@ def create_safe(
209
207
  contract_address="0xa6b71e26c5e0845f74c812102ca7114b6a896ab2",
210
208
  )
211
209
  (event,) = instance.events.ProxyCreation().process_receipt(receipt)
212
- return event["args"]["proxy"], salt_nonce, tx_hash
210
+ safe_address = event["args"]["proxy"]
211
+
212
+ if backup_owner is not None:
213
+ retry_delays = [0, 60, 120, 180, 240]
214
+ for attempt in range(1, len(retry_delays) + 1):
215
+ try:
216
+ add_owner(
217
+ ledger_api=ledger_api,
218
+ crypto=crypto,
219
+ safe=safe_address,
220
+ owner=backup_owner,
221
+ )
222
+ break # success
223
+ except Exception as e: # pylint: disable=broad-except
224
+ if attempt == len(retry_delays):
225
+ raise RuntimeError(
226
+ f"Failed to add backup owner {backup_owner} after {len(retry_delays)} attempts: {e}"
227
+ ) from e
228
+ next_delay = retry_delays[attempt]
229
+ logger.error(
230
+ f"Retry add owner {attempt}/{len(retry_delays)} in {next_delay} seconds due to error: {e}"
231
+ )
232
+ time.sleep(next_delay)
233
+
234
+ return safe_address, salt_nonce, tx_hash
213
235
 
214
236
 
215
237
  def get_owners(ledger_api: LedgerApi, safe: str) -> t.List[str]:
@@ -274,6 +296,9 @@ def send_safe_txs(
274
296
  chain_type=Chain.from_id(
275
297
  ledger_api._chain_id # pylint: disable=protected-access
276
298
  ),
299
+ timeout=ON_CHAIN_INTERACT_TIMEOUT,
300
+ retries=ON_CHAIN_INTERACT_RETRIES,
301
+ sleep=ON_CHAIN_INTERACT_SLEEP,
277
302
  )
278
303
  setattr(tx_settler, "build", _build_tx) # noqa: B010
279
304
  tx_receipt = tx_settler.transact(
@@ -443,6 +468,9 @@ def transfer(
443
468
  chain_type=Chain.from_id(
444
469
  ledger_api._chain_id # pylint: disable=protected-access
445
470
  ),
471
+ timeout=ON_CHAIN_INTERACT_TIMEOUT,
472
+ retries=ON_CHAIN_INTERACT_RETRIES,
473
+ sleep=ON_CHAIN_INTERACT_SLEEP,
446
474
  )
447
475
  setattr(tx_settler, "build", _build_tx) # noqa: B010
448
476
  tx_receipt = tx_settler.transact(
@@ -485,6 +513,33 @@ def transfer_erc20_from_safe(
485
513
  )
486
514
 
487
515
 
516
+ def estimate_transfer_tx_fee(chain: Chain, sender_address: str, to: str) -> int:
517
+ """Estimate transfer transaction fee."""
518
+ ledger_api = get_default_ledger_api(chain)
519
+ tx = ledger_api.get_transfer_transaction(
520
+ sender_address=sender_address,
521
+ destination_address=to,
522
+ amount=0,
523
+ tx_fee=0,
524
+ tx_nonce="0x",
525
+ chain_id=chain.id,
526
+ raise_on_try=True,
527
+ )
528
+ tx = ledger_api.update_with_gas_estimate(
529
+ transaction=tx,
530
+ raise_on_try=False,
531
+ )
532
+ chain_fee = tx["gas"] * tx["maxFeePerGas"]
533
+ if chain in (
534
+ Chain.ARBITRUM_ONE,
535
+ Chain.BASE,
536
+ Chain.OPTIMISM,
537
+ Chain.MODE,
538
+ ):
539
+ chain_fee += ledger_api.get_l1_data_fee(tx)
540
+ return chain_fee
541
+
542
+
488
543
  def drain_eoa(
489
544
  ledger_api: LedgerApi,
490
545
  crypto: Crypto,
@@ -505,34 +560,34 @@ def drain_eoa(
505
560
  *args: t.Any, **kwargs: t.Any
506
561
  ) -> t.Dict:
507
562
  """Build transaction"""
563
+ chain_fee = estimate_transfer_tx_fee(
564
+ chain=Chain.from_id(chain_id),
565
+ sender_address=crypto.address,
566
+ to=withdrawal_address,
567
+ )
568
+
569
+ amount = ledger_api.get_balance(crypto.address) - chain_fee
570
+ if amount <= 0:
571
+ raise ChainInteractionError(
572
+ f"No balance to drain from wallet: {crypto.address}"
573
+ )
574
+
508
575
  tx = ledger_api.get_transfer_transaction(
509
576
  sender_address=crypto.address,
510
577
  destination_address=withdrawal_address,
511
- amount=0,
578
+ amount=amount,
512
579
  tx_fee=0,
513
580
  tx_nonce="0x",
514
581
  chain_id=chain_id,
515
582
  raise_on_try=True,
516
583
  )
517
- tx = ledger_api.update_with_gas_estimate(
518
- transaction=tx,
584
+ empty_tx = tx.copy()
585
+ empty_tx["value"] = 0
586
+ empty_tx = ledger_api.update_with_gas_estimate(
587
+ transaction=empty_tx,
519
588
  raise_on_try=False,
520
589
  )
521
-
522
- chain_fee = tx["gas"] * tx["maxFeePerGas"]
523
- if Chain.from_id(chain_id) in (
524
- Chain.ARBITRUM_ONE,
525
- Chain.BASE,
526
- Chain.OPTIMISM,
527
- Chain.MODE,
528
- ):
529
- chain_fee += ledger_api.get_l1_data_fee(tx)
530
-
531
- tx["value"] = ledger_api.get_balance(crypto.address) - chain_fee
532
- if tx["value"] <= 0:
533
- raise ChainInteractionError(
534
- f"No balance to drain from wallet: {crypto.address}"
535
- )
590
+ tx["gas"] = empty_tx["gas"]
536
591
 
537
592
  logger.info(
538
593
  f"Draining {tx['value']} native units from wallet: {crypto.address}"
@@ -0,0 +1,226 @@
1
+ # -*- coding: utf-8 -*-
2
+ # ------------------------------------------------------------------------------
3
+ #
4
+ # Copyright 2025 Valory AG
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ # ------------------------------------------------------------------------------
19
+
20
+ """Utility module for enforcing single-instance application behavior and monitoring parent process."""
21
+
22
+ import asyncio
23
+ import logging
24
+ import os
25
+ import socket
26
+ import time
27
+ from contextlib import suppress
28
+ from typing import Callable, Optional
29
+
30
+ import psutil
31
+ import requests
32
+
33
+
34
+ class AppSingleInstance:
35
+ """Ensure that only one instance of an application is running."""
36
+
37
+ host = "127.0.0.1"
38
+ after_kill_sleep_time = 1
39
+ proc_kill_wait_timeout = 10
40
+ proc_terminate_wait_timeout = 10
41
+ http_request_timeout = 3
42
+
43
+ def __init__(self, port_number: int, shutdown_endpoint: str = "/shutdown") -> None:
44
+ """Initialize the AppSingleInstance manager."""
45
+ self.port_number = port_number
46
+ self.shutdown_endpoint = shutdown_endpoint
47
+ self.logger = logging.getLogger("app_single_instance")
48
+ self.logger.setLevel(logging.DEBUG)
49
+
50
+ @staticmethod
51
+ def is_port_in_use(port: int) -> bool:
52
+ """Return True if a given TCP port is currently in use."""
53
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
54
+ return s.connect_ex(("127.0.0.1", port)) == 0
55
+
56
+ def shutdown_previous_instance(self) -> None:
57
+ """Attempt to stop a previously running instance of the application."""
58
+ if not self.is_port_in_use(self.port_number):
59
+ self.logger.info(f"Port {self.port_number} is free. All good.")
60
+ return
61
+
62
+ self.logger.warning(f"Port {self.port_number} is in use. Trying to free it!")
63
+ self.logger.warning(
64
+ f"Trying to stop previous instance via shutdown endpoint: {self.shutdown_endpoint}"
65
+ )
66
+ self.try_shutdown_with_endpoint()
67
+
68
+ if not self.is_port_in_use(self.port_number):
69
+ self.logger.info(f"Port {self.port_number} is free. All good.")
70
+ return
71
+
72
+ self.logger.warning(
73
+ f"Trying to stop previous instance by killing process using port {self.port_number}"
74
+ )
75
+ self.try_kill_proc_using_port()
76
+
77
+ if not self.is_port_in_use(self.port_number):
78
+ self.logger.info(f"Port {self.port_number} is free. All good.")
79
+ return
80
+
81
+ self.logger.error(f"Port {self.port_number} still in use. Cannot continue.")
82
+ raise RuntimeError(f"Port {self.port_number} is in use, cannot continue!")
83
+
84
+ def try_shutdown_with_endpoint(self) -> None:
85
+ """Attempt to gracefully shut down the previous instance via HTTP or HTTPS."""
86
+ try:
87
+ self.logger.warning(
88
+ "Attempting to stop previous instance via HTTPS shutdown endpoint."
89
+ )
90
+ requests.get(
91
+ f"https://{self.host}:{self.port_number}{self.shutdown_endpoint}",
92
+ timeout=self.http_request_timeout,
93
+ verify=False, # nosec
94
+ )
95
+ time.sleep(self.after_kill_sleep_time)
96
+ except requests.exceptions.SSLError:
97
+ self.logger.warning("HTTPS shutdown failed, retrying without SSL.")
98
+ try:
99
+ requests.get(
100
+ f"http://{self.host}:{self.port_number}{self.shutdown_endpoint}",
101
+ timeout=self.http_request_timeout,
102
+ )
103
+ time.sleep(self.after_kill_sleep_time)
104
+ except Exception as e: # pylint: disable=broad-except
105
+ self.logger.error(
106
+ f"Failed to stop previous instance (HTTP). Error: {e}"
107
+ )
108
+ except Exception as e: # pylint: disable=broad-except
109
+ self.logger.error(f"Failed to stop previous instance (HTTPS). Error: {e}")
110
+
111
+ def try_kill_proc_using_port(self) -> None:
112
+ """Attempt to forcibly terminate the process occupying the target port."""
113
+ for conn in psutil.net_connections(kind="tcp"):
114
+ if (
115
+ conn.laddr.port == self.port_number
116
+ and conn.status == psutil.CONN_LISTEN
117
+ ):
118
+ if conn.pid is None:
119
+ self.logger.info(
120
+ f"Process using port {self.port_number} found but PID is None. Cannot continue."
121
+ )
122
+ return
123
+ self.logger.info(
124
+ f"Process using port {self.port_number} found (PID={conn.pid}). Terminating..."
125
+ )
126
+ try:
127
+ self.kill_process_tree(conn.pid)
128
+ time.sleep(self.after_kill_sleep_time)
129
+ return
130
+ except Exception as e: # pylint: disable=broad-except
131
+ self.logger.error(f"Error stopping process {conn.pid}: {e}")
132
+ self.logger.info(f"No process found using port {self.port_number}.")
133
+
134
+ def kill_process_tree(self, pid: int) -> None:
135
+ """Terminate a process and all its child processes."""
136
+ try:
137
+ parent = psutil.Process(pid)
138
+ children = parent.children(recursive=True)
139
+
140
+ for child in children:
141
+ with suppress(psutil.NoSuchProcess):
142
+ child.terminate()
143
+
144
+ _, still_alive = psutil.wait_procs(
145
+ children, timeout=self.proc_terminate_wait_timeout
146
+ )
147
+
148
+ for child in still_alive:
149
+ with suppress(psutil.NoSuchProcess):
150
+ child.kill()
151
+
152
+ parent.terminate()
153
+ try:
154
+ parent.wait(timeout=self.proc_terminate_wait_timeout)
155
+ except psutil.TimeoutExpired:
156
+ parent.kill()
157
+ parent.wait(timeout=self.proc_kill_wait_timeout)
158
+
159
+ except psutil.NoSuchProcess:
160
+ self.logger.info(f"Process {pid} already terminated.")
161
+ except Exception as e: # pylint: disable=broad-except
162
+ self.logger.error(f"Error killing process {pid}: {e}")
163
+
164
+
165
+ logger = logging.getLogger("parent_watchdog")
166
+
167
+
168
+ class ParentWatchdog:
169
+ """Monitor the parent process and trigger a shutdown when it exits."""
170
+
171
+ def __init__(
172
+ self, on_parent_exit: Callable[[], asyncio.Future], check_interval: int = 3
173
+ ) -> None:
174
+ """Initialize the ParentWatchdog."""
175
+ self.on_parent_exit = on_parent_exit
176
+ self.check_interval = check_interval
177
+ self._task: Optional[asyncio.Task] = None
178
+ self._stopping = False
179
+
180
+ async def _watch_loop(self) -> None:
181
+ """Continuously monitor the parent process and invoke the shutdown callback when it exits."""
182
+ try:
183
+ own_pid = os.getpid()
184
+ logger.info(f"ParentWatchdog started (pid={own_pid}, ppid={os.getppid()})")
185
+
186
+ while not self._stopping:
187
+ try:
188
+ parent = psutil.Process(os.getppid())
189
+ if not parent.is_running() or os.getppid() == 1:
190
+ logger.warning(
191
+ "Parent process no longer alive, initiating shutdown."
192
+ )
193
+ await self.on_parent_exit()
194
+ break
195
+ except psutil.NoSuchProcess:
196
+ logger.warning("Parent process not found, initiating shutdown.")
197
+ await self.on_parent_exit()
198
+ break
199
+ except Exception: # pylint: disable=broad-except
200
+ logger.exception("Parent check iteration failed.")
201
+ await asyncio.sleep(self.check_interval)
202
+
203
+ except asyncio.CancelledError:
204
+ logger.info("ParentWatchdog task cancelled.")
205
+ except Exception: # pylint: disable=broad-except
206
+ logger.exception("ParentWatchdog crashed unexpectedly.")
207
+ finally:
208
+ logger.info("ParentWatchdog stopped.")
209
+
210
+ def start(self, loop: Optional[asyncio.AbstractEventLoop] = None) -> asyncio.Task:
211
+ """Start monitoring the parent process."""
212
+ if self._task:
213
+ logger.warning("ParentWatchdog already running.")
214
+ return self._task
215
+ loop = loop or asyncio.get_running_loop()
216
+ self._task = loop.create_task(self._watch_loop())
217
+ return self._task
218
+
219
+ async def stop(self) -> None:
220
+ """Stop the parent process watchdog."""
221
+ self._stopping = True
222
+ if self._task:
223
+ with suppress(Exception):
224
+ self._task.cancel()
225
+ await self._task
226
+ self._task = None