Skip to content

Commit 86769e3

Browse files
committed
fix: persist media server port and wait for waku connection status
Pass media server bind/advertize options in InitializeApplication and replace the removed wakuv2.peerstats signal with waku.connection.status.change. Publish port 8081 in docker-compose and add a pytest + GitHub Actions check that the media server port survives logout/re-init.
1 parent be0650d commit 86769e3

5 files changed

Lines changed: 277 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
media-port:
9+
runs-on: ubuntu-latest
10+
timeout-minutes: 30
11+
steps:
12+
- name: Checkout
13+
uses: actions/checkout@v4
14+
15+
- name: Set up Python
16+
uses: actions/setup-python@v5
17+
with:
18+
python-version: "3.12"
19+
20+
- name: Install dependencies
21+
run: pip install -r requirements.txt
22+
23+
- name: Start status-backend
24+
run: docker compose up -d --build
25+
26+
- name: Wait for backend health
27+
run: |
28+
for i in $(seq 1 60); do
29+
if curl -fsS http://127.0.0.1:8080/health >/dev/null; then
30+
exit 0
31+
fi
32+
sleep 5
33+
done
34+
echo "backend did not become healthy in time"
35+
docker compose logs
36+
exit 1
37+
38+
- name: Run media server port test
39+
run: pytest tests/test_media_server_port.py -v
40+
41+
- name: Dump backend logs on failure
42+
if: failure()
43+
run: docker compose logs
44+
45+
- name: Stop containers
46+
if: always()
47+
run: docker compose down

bot/account.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class Account:
3333
}
3434
__ETH_ADDRESS = "0x0000000000000000000000000000000000000000"
3535

36-
def __init__(self, domain: str = "localhost", port: int = 8080, is_secure: bool = False, backup_folder: Optional[str] = None):
36+
def __init__(self, domain: str = "localhost", port: int = 8080, is_secure: bool = False, backup_folder: Optional[str] = None, media_port: int = 8081):
3737
"""
3838
Work with your own Status App account
3939
@@ -42,7 +42,10 @@ def __init__(self, domain: str = "localhost", port: int = 8080, is_secure: bool
4242
- `port` - the port to connect to Status Backend. Verify the port in the Docker files.
4343
- `is_secure` - if `http` or `https` should be used
4444
- `backup_folder` - where backup files will be created and stored
45+
- `media_port` - fixed media server port (advertized in localUrl as domain:media_port)
4546
"""
47+
self.__domain = domain
48+
self.__media_port = media_port
4649
# Wallet transactions
4750
self.__alchemy_token = None
4851
self.__transactions: Optional[pd.DataFrame] = None
@@ -103,6 +106,14 @@ def __init__(self, domain: str = "localhost", port: int = 8080, is_secure: bool
103106
# In case if there is a hanging logged in session
104107
self.logout()
105108

109+
def __initialize_application_payload(self) -> dict:
110+
return {
111+
"dataDir": self.__docker_data_folder,
112+
"mediaServerAddress": f"0.0.0.0:{self.__media_port}",
113+
"mediaServerAdvertizeHost": self.__domain,
114+
"mediaServerAdvertizePort": self.__media_port,
115+
}
116+
106117
def login(self, password: str, key_uid: Optional[str] = None, name: Optional[str] = None, mnemonic: Optional[str] = None, infura_token: Optional[str] = None, alchemy_token: Optional[str] = None, coingecko_api_key: Optional[str] = None):
107118
"""
108119
Login to the given account. If it does not exist,
@@ -252,9 +263,10 @@ def available_accounts(self) -> list[dict]:
252263
"""
253264
All locally available accounts
254265
"""
255-
response = requests.post(self.__urls["http"]["initialize"], json={
256-
"dataDir": self.__docker_data_folder
257-
})
266+
response = requests.post(
267+
self.__urls["http"]["initialize"],
268+
json=self.__initialize_application_payload(),
269+
)
258270
data: dict = response.json()
259271
accounts: list[dict] = data.get("accounts", [])
260272
if not isinstance(accounts, list):
@@ -1323,7 +1335,8 @@ def __start_messenger(self):
13231335
return
13241336
self.logger.info("Starting messaging")
13251337
self.__call_rpc("messaging", "startMessenger")
1326-
self.__signal.get("wakuv2.peerstats")
1338+
with self.__signal.expect("waku.connection.status.change", timeout=60):
1339+
pass
13271340
self.__is_messenger_launched = True
13281341
self.logger.info("Messaging launched")
13291342

docker-compose.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ services:
22
backend:
33
build:
44
context: https://github.com/status-im/status-go.git#develop
5-
platform: linux/amd64
65
container_name: status-backend
76
ports:
87
- 8080:8080
8+
- 8081:8081
99
- 8545:8545
1010
- 30303:30303
1111
entrypoint: 'status-backend'
@@ -16,7 +16,10 @@ services:
1616
networks:
1717
- status-bridge
1818
healthcheck:
19-
test: ["CMD", "curl http://0.0.0.0:8080/health"]
19+
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/health || exit 1"]
20+
interval: 10s
21+
timeout: 5s
22+
retries: 30
2023

2124
networks:
2225
status-bridge:

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ websockets
44
pandas
55
pillow
66
eth-abi
7+
pytest

tests/test_media_server_port.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"""Media server port stays fixed after logout -> InitializeApplication -> login."""
2+
3+
import json
4+
import threading
5+
import time
6+
7+
import pytest
8+
import requests
9+
import urllib3
10+
import websocket
11+
12+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
13+
14+
HTTP_BASE = "http://127.0.0.1:8080/statusgo"
15+
WS_URL = "ws://127.0.0.1:8080/signals"
16+
DATA_DIR = "./data-dir"
17+
MEDIA_PORT = 8081
18+
PASSWORD = "StatusSdkPortTest1"
19+
20+
21+
class SignalBuffer:
22+
def __init__(self, url: str):
23+
self.url = url
24+
self.by_type: dict[str, list[dict]] = {}
25+
self._cond = threading.Condition()
26+
self._stop = False
27+
self._thread = threading.Thread(target=self._run, daemon=True)
28+
self._thread.start()
29+
time.sleep(0.3)
30+
31+
def _run(self):
32+
while not self._stop:
33+
34+
def on_message(_ws, raw: str):
35+
try:
36+
msg = json.loads(raw)
37+
except json.JSONDecodeError:
38+
return
39+
typ = msg.get("type")
40+
if not typ:
41+
return
42+
with self._cond:
43+
self.by_type.setdefault(typ, []).append(msg)
44+
self._cond.notify_all()
45+
46+
wsapp = websocket.WebSocketApp(self.url, on_message=on_message)
47+
wsapp.run_forever()
48+
if not self._stop:
49+
time.sleep(0.5)
50+
51+
def count(self, signal_type: str) -> int:
52+
with self._cond:
53+
return len(self.by_type.get(signal_type, []))
54+
55+
def wait_for(self, signal_type: str, timeout: float = 90) -> dict:
56+
deadline = time.monotonic() + timeout
57+
with self._cond:
58+
while time.monotonic() < deadline:
59+
buf = self.by_type.get(signal_type, [])
60+
if buf:
61+
return buf[-1]
62+
self._cond.wait(timeout=0.5)
63+
raise TimeoutError(f"signal {signal_type!r} not received within {timeout}s")
64+
65+
def latest_port(self, signal_type: str, since: int) -> int | None:
66+
with self._cond:
67+
signals = self.by_type.get(signal_type, [])[since:]
68+
for msg in reversed(signals):
69+
port = (msg.get("event") or {}).get("port")
70+
if port is not None:
71+
return int(port)
72+
return None
73+
74+
def close(self):
75+
self._stop = True
76+
77+
78+
def post_json(path: str, payload: dict | None = None) -> dict:
79+
resp = requests.post(f"{HTTP_BASE}/{path}", json=payload or {}, timeout=60)
80+
resp.raise_for_status()
81+
if not resp.content:
82+
return {}
83+
return resp.json()
84+
85+
86+
def wait_backend_healthy(timeout: float = 180) -> None:
87+
deadline = time.monotonic() + timeout
88+
last_exc: Exception | None = None
89+
while time.monotonic() < deadline:
90+
try:
91+
if requests.get("http://127.0.0.1:8080/health", timeout=5).status_code == 200:
92+
return
93+
except requests.RequestException as exc:
94+
last_exc = exc
95+
time.sleep(2)
96+
raise RuntimeError(f"backend not healthy within {timeout}s: {last_exc}")
97+
98+
99+
def initialize() -> dict:
100+
return post_json(
101+
"InitializeApplication",
102+
{
103+
"dataDir": DATA_DIR,
104+
"apiLoggingEnabled": True,
105+
"mediaServerAddress": f"0.0.0.0:{MEDIA_PORT}",
106+
"mediaServerAdvertizeHost": "localhost",
107+
"mediaServerAdvertizePort": MEDIA_PORT,
108+
},
109+
)
110+
111+
112+
def logout(signals: SignalBuffer | None = None) -> None:
113+
try:
114+
post_json("Logout")
115+
if signals is not None:
116+
try:
117+
signals.wait_for("node.stopped", timeout=30)
118+
except TimeoutError:
119+
pass
120+
except requests.RequestException:
121+
pass
122+
123+
124+
def media_server_health_ok(port: int) -> bool:
125+
try:
126+
r = requests.get(f"https://127.0.0.1:{port}/health", timeout=10, verify=False)
127+
return r.status_code == 200
128+
except requests.RequestException:
129+
return False
130+
131+
132+
def observed_port(signals: SignalBuffer, since: int) -> int | None:
133+
signal_port = signals.latest_port("mediaserver.started", since)
134+
if signal_port is not None:
135+
return signal_port
136+
if media_server_health_ok(MEDIA_PORT):
137+
return MEDIA_PORT
138+
return None
139+
140+
141+
def ensure_account(signals: SignalBuffer, init_data: dict) -> str:
142+
accounts = init_data.get("accounts") or []
143+
if accounts:
144+
return accounts[0]["key-uid"]
145+
146+
post_json(
147+
"CreateAccountAndLogin",
148+
{
149+
"rootDataDir": DATA_DIR,
150+
"kdfIterations": 256000,
151+
"displayName": "PortCheck",
152+
"password": PASSWORD,
153+
"customizationColor": "primary",
154+
"wakuV2LightClient": False,
155+
"thirdpartyServicesEnabled": True,
156+
"logEnabled": True,
157+
"logLevel": "INFO",
158+
},
159+
)
160+
signals.wait_for("node.login")
161+
init_data = initialize()
162+
accounts = init_data.get("accounts") or []
163+
assert accounts, "no accounts after CreateAccountAndLogin"
164+
return accounts[0]["key-uid"]
165+
166+
167+
def login(key_uid: str, signals: SignalBuffer) -> None:
168+
post_json(
169+
"LoginAccount",
170+
{
171+
"keyUid": key_uid,
172+
"password": PASSWORD,
173+
"kdfIterations": 256000,
174+
"thirdpartyServicesEnabled": True,
175+
},
176+
)
177+
event = signals.wait_for("node.login")
178+
err = (event.get("event") or {}).get("error")
179+
if err:
180+
raise RuntimeError(f"login error: {err}")
181+
182+
183+
@pytest.fixture
184+
def signals():
185+
buf = SignalBuffer(WS_URL)
186+
yield buf
187+
buf.close()
188+
logout(buf)
189+
190+
191+
def test_media_port_persists_across_logout_reinit(signals):
192+
wait_backend_healthy()
193+
logout(signals)
194+
since = signals.count("mediaserver.started")
195+
init_data = initialize()
196+
key_uid = ensure_account(signals, init_data)
197+
login(key_uid, signals)
198+
port1 = observed_port(signals, since)
199+
assert port1 == MEDIA_PORT, f"first login: expected {MEDIA_PORT}, got {port1}"
200+
201+
logout(signals)
202+
since = signals.count("mediaserver.started")
203+
initialize()
204+
login(key_uid, signals)
205+
port2 = observed_port(signals, since)
206+
assert port2 == MEDIA_PORT, f"after re-init: expected {MEDIA_PORT}, got {port2}"

0 commit comments

Comments
 (0)