Skip to content

Commit b5c7766

Browse files
committed
[Node] handle full bot start
1 parent 3f2a4d5 commit b5c7766

31 files changed

Lines changed: 1912 additions & 103 deletions

File tree

packages/commons/octobot_commons/os_util.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,27 @@ def tcp_port_is_free(bind_host: str, port: int) -> bool:
194194
return True
195195

196196

197+
_HOST_WIDE_LISTENER_PROBE_HOSTS = ("0.0.0.0", "127.0.0.1")
198+
199+
200+
def tcp_port_has_listener_on_host(port: int) -> bool:
201+
"""
202+
:return: True if any TCP listener on this machine uses ``port``
203+
"""
204+
try:
205+
for connection in psutil.net_connections(kind="tcp"):
206+
if connection.status != psutil.CONN_LISTEN:
207+
continue
208+
local_address = connection.laddr
209+
if local_address is not None and local_address.port == port:
210+
return True
211+
except (psutil.AccessDenied, psutil.Error):
212+
for probe_host in _HOST_WIDE_LISTENER_PROBE_HOSTS:
213+
if not tcp_port_is_free(probe_host, port):
214+
return True
215+
return False
216+
217+
197218
def find_first_free_listen_port_after_base(
198219
bind_host_for_probe: str,
199220
listen_port_base: int,
@@ -202,13 +223,15 @@ def find_first_free_listen_port_after_base(
202223
) -> int:
203224
"""
204225
First offset where ``listen_port_base + offset`` is TCP-free on ``bind_host_for_probe``
205-
(optional: require ``paired_listen_port_base + offset`` free as well, same scan step).
226+
and not in use by any listener on this host.
206227
Returns ``listen_port``.
207228
"""
208229
for offset_from_base in range(max_offset):
209230
listen_port = listen_port_base + offset_from_base
210231
if blocklist and listen_port in blocklist:
211232
continue
233+
if tcp_port_has_listener_on_host(listen_port):
234+
continue
212235
if not tcp_port_is_free(bind_host_for_probe, listen_port):
213236
continue
214237
return listen_port

packages/commons/tests/test_os_util.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,27 +45,60 @@ def test_returns_true_after_listener_released(self):
4545
assert os_util.tcp_port_is_free("127.0.0.1", bound_port) is True
4646

4747

48+
class TestTcpPortHasListenerOnHost:
49+
def test_returns_true_when_listener_holds_port(self):
50+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
51+
listener.bind(("127.0.0.1", 0))
52+
listener.listen(1)
53+
bound_port = listener.getsockname()[1]
54+
assert os_util.tcp_port_has_listener_on_host(bound_port) is True
55+
56+
def test_returns_false_after_listener_released(self):
57+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
58+
listener.bind(("127.0.0.1", 0))
59+
bound_port = listener.getsockname()[1]
60+
assert os_util.tcp_port_has_listener_on_host(bound_port) is False
61+
62+
4863
class TestFindFirstFreeListenPortAfterBase:
4964
def test_returns_base_when_free(self):
50-
with mock.patch.object(os_util, "tcp_port_is_free", return_value=True):
65+
with mock.patch.object(os_util, "tcp_port_has_listener_on_host", return_value=False), mock.patch.object(
66+
os_util, "tcp_port_is_free", return_value=True
67+
):
5168
listen_port = os_util.find_first_free_listen_port_after_base("127.0.0.1", 50000)
5269
assert listen_port == 50000
5370

5471
def test_skips_until_first_free_port(self):
55-
with mock.patch.object(os_util, "tcp_port_is_free", side_effect=[False, False, True]):
72+
with mock.patch.object(os_util, "tcp_port_has_listener_on_host", return_value=False), mock.patch.object(
73+
os_util, "tcp_port_is_free", side_effect=[False, False, True]
74+
):
5675
listen_port = os_util.find_first_free_listen_port_after_base("127.0.0.1", 50100)
5776
assert listen_port == 50102
5877

5978
def test_skips_blocklisted_ports(self):
60-
with mock.patch.object(os_util, "tcp_port_is_free", return_value=True):
79+
with mock.patch.object(os_util, "tcp_port_has_listener_on_host", return_value=False), mock.patch.object(
80+
os_util, "tcp_port_is_free", return_value=True
81+
):
6182
listen_port = os_util.find_first_free_listen_port_after_base(
6283
"127.0.0.1",
6384
50200,
6485
blocklist=[50200],
6586
)
6687
assert listen_port == 50201
6788

89+
def test_skips_port_with_host_wide_listener(self):
90+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
91+
listener.bind(("127.0.0.1", 0))
92+
base_port = listener.getsockname()[1]
93+
listener.listen(1)
94+
listen_port = os_util.find_first_free_listen_port_after_base(
95+
"127.0.0.1", base_port, max_offset=5
96+
)
97+
assert listen_port == base_port + 1
98+
6899
def test_raises_when_scan_exhausted(self):
69-
with mock.patch.object(os_util, "tcp_port_is_free", return_value=False):
100+
with mock.patch.object(os_util, "tcp_port_has_listener_on_host", return_value=False), mock.patch.object(
101+
os_util, "tcp_port_is_free", return_value=False
102+
):
70103
with pytest.raises(ValueError, match="No free listen port"):
71104
os_util.find_first_free_listen_port_after_base("127.0.0.1", 50300, max_offset=2)

packages/flow/tests/functionnal_tests/octobot_process_actions/octobot_process_functional_shared.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
EXPECTED_PROCESS_BOT_DUMP_INTERVAL_SEC = 5.0
3939

4040
# Same as `waiting_time=` in run_octobot_process(...) DSL for this file's tests.
41-
WAITING_TIME_RUN_OCTOBOT_PROCESS_SEC = 2.0
41+
WAITING_TIME_RUN_OCTOBOT_PROCESS_SEC = 2
4242
RECALL_SCHEDULE_TOLERANCE_SEC = 1.5
4343

4444
EXCHANGE_BINANCEUS = "binanceus"

packages/flow/tests/functionnal_tests/octobot_process_actions/test_octobot_process_edit_config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ async def test_run_octobot_process_grid_refresh_four_to_six_orders(
8282
run_dsl = (
8383
"run_octobot_process("
8484
f"{user_folder!r}, {repr(profile_2x2)}, "
85-
"waiting_time=2.0, ping_timeout=30.0)"
85+
f"waiting_time={octobot_process_functional_shared.WAITING_TIME_RUN_OCTOBOT_PROCESS_SEC}, ping_timeout=30.0)"
8686
)
8787
run_action = {
8888
"id": octobot_process_functional_shared.ACTION_ID_RUN_OCTOBOT,
@@ -231,7 +231,7 @@ async def test_run_octobot_process_grid_refresh_four_to_six_orders(
231231
new_run_dsl = (
232232
"run_octobot_process("
233233
f"{user_folder!r}, {repr(profile_3x3)}, "
234-
"waiting_time=2.0, ping_timeout=30.0)"
234+
f"waiting_time={octobot_process_functional_shared.WAITING_TIME_RUN_OCTOBOT_PROCESS_SEC}, ping_timeout=30.0)"
235235
)
236236
update_config_priority_action = {
237237
"id": ACTION_ID_UPDATE_AUTOMATION_CONFIGURATION,

packages/flow/tests/functionnal_tests/octobot_process_actions/test_octobot_process_start.py

Lines changed: 211 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44
import asyncio
55
import json
66
import os
7+
import pathlib
78
import shutil
89
import time
910
import typing
1011
import uuid
1112

1213
import mock
1314
import octobot.constants as octobot_app_constants
15+
import octobot_commons.configuration as configuration_module
1416
import octobot_commons.constants as common_constants
17+
import octobot_commons.dsl_interpreter as dsl_interpreter
1518
import octobot_commons.process_util as process_util
1619
import octobot_node.constants as octobot_node_constants
1720
import pytest
@@ -43,7 +46,7 @@ async def test_run_octobot_process_lifecycle_grid_trading(
4346
run_dsl = (
4447
"run_octobot_process("
4548
f"{user_folder!r}, {repr(octobot_process_functional_shared.GRID_BINANCEUS_PROFILE_DATA)}, "
46-
"waiting_time=2.0, ping_timeout=30.0)"
49+
f"waiting_time={octobot_process_functional_shared.WAITING_TIME_RUN_OCTOBOT_PROCESS_SEC}, ping_timeout=30.0)"
4750
)
4851
run_action = {
4952
"id": octobot_process_functional_shared.ACTION_ID_RUN_OCTOBOT,
@@ -298,3 +301,210 @@ async def test_run_octobot_process_lifecycle_grid_trading(
298301
shutil.rmtree(user_root_guess, ignore_errors=True)
299302
if os.path.isdir(log_folder_guess):
300303
shutil.rmtree(log_folder_guess, ignore_errors=True)
304+
305+
306+
async def test_run_octobot_process_lifecycle_default_config_no_profile_data(
307+
init_action: dict,
308+
monkeypatch: pytest.MonkeyPatch,
309+
):
310+
if not os.path.isfile(os.path.join(os.getcwd(), "start.py")):
311+
pytest.skip("start.py missing: run pytest with cwd set to the OctoBot project root")
312+
313+
non_trading_profile_json = os.path.join(
314+
os.getcwd(),
315+
common_constants.USER_FOLDER,
316+
common_constants.PROFILES_FOLDER,
317+
"non-trading",
318+
common_constants.PROFILE_CONFIG_FILE,
319+
)
320+
if not os.path.isfile(non_trading_profile_json):
321+
pytest.skip("non-trading profile missing under OctoBot user/profiles")
322+
323+
monkeypatch.setenv(octobot_app_constants.ENV_PROCESS_BOT_STATE_DUMP_INTERVAL_SECONDS, "5")
324+
325+
user_folder = f"functionnal_tests/octlife_default_{uuid.uuid4().hex[:12]}"
326+
exchange_auth = [
327+
{
328+
"internal_name": octobot_process_functional_shared.EXCHANGE_BINANCEUS,
329+
"api_key": "functional-default-config-key",
330+
"api_secret": "functional-default-config-secret",
331+
"sandboxed": True,
332+
"exchange_type": common_constants.CONFIG_EXCHANGE_SPOT,
333+
}
334+
]
335+
run_dsl = (
336+
f"run_octobot_process({user_folder!r}, "
337+
f"exchange_auth_data={dsl_interpreter.format_parameter_value(exchange_auth)}, "
338+
f"waiting_time={octobot_process_functional_shared.WAITING_TIME_RUN_OCTOBOT_PROCESS_SEC}, ping_timeout=30.0)"
339+
)
340+
run_action = {
341+
"id": octobot_process_functional_shared.ACTION_ID_RUN_OCTOBOT,
342+
"dsl_script": run_dsl,
343+
"dependencies": [{"action_id": octobot_process_functional_shared.ACTION_ID_INIT}],
344+
}
345+
stop_automation_action = {
346+
"id": octobot_process_functional_shared.ACTION_ID_STOP_AUTOMATION,
347+
"dsl_script": "stop_automation()",
348+
"dependencies": [{"action_id": octobot_process_functional_shared.ACTION_ID_INIT}],
349+
}
350+
351+
popen_calls = {"count": 0}
352+
tracked_spawn_managed = (
353+
octobot_process_functional_shared._make_tracked_spawn_managed_with_forward_terminal_output(
354+
process_util.spawn_managed_subprocess,
355+
popen_calls,
356+
)
357+
)
358+
359+
user_root_guess = os.path.normpath(
360+
os.path.join(
361+
os.getcwd(),
362+
*common_constants.USER_AUTOMATIONS_FOLDER.split("/"),
363+
*user_folder.replace("\\", "/").split("/"),
364+
)
365+
)
366+
log_folder_guess = os.path.normpath(
367+
os.path.join(
368+
os.getcwd(),
369+
*octobot_node_constants.AUTOMATION_LOGS_FOLDER.split("/"),
370+
*[segment for segment in user_folder.replace("\\", "/").split("/") if segment],
371+
)
372+
)
373+
374+
try:
375+
with (
376+
functionnal_tests.mocked_community_authentication(),
377+
functionnal_tests.mocked_community_repository(),
378+
mock.patch.object(
379+
process_util,
380+
"spawn_managed_subprocess",
381+
side_effect=tracked_spawn_managed,
382+
),
383+
):
384+
state = functionnal_tests.automation_state_dict(
385+
functionnal_tests.resolved_actions([init_action])
386+
)
387+
async with octobot_flow.jobs.AutomationJob(state, [], [], {}) as init_job:
388+
await init_job.run()
389+
state = init_job.dump()
390+
391+
async with octobot_flow.jobs.AutomationJob(state, [], [], {}) as job:
392+
job.automation_state.upsert_automation_actions(
393+
functionnal_tests.resolved_actions([run_action])
394+
)
395+
state = job.dump()
396+
397+
deadline = time.monotonic() + octobot_process_functional_shared.GLOBAL_START_TIMEOUT_SEC
398+
inner: typing.Optional[dict] = None
399+
async with octobot_flow.jobs.AutomationJob(state, [], [], {}) as first_poll:
400+
await first_poll.run()
401+
octobot_process_functional_shared._assert_run_octobot_process_recall_scheduled_to_in_dump(
402+
first_poll.dump()
403+
)
404+
first_run = octobot_process_functional_shared._get_action_by_id(
405+
first_poll, octobot_process_functional_shared.ACTION_ID_RUN_OCTOBOT
406+
)
407+
assert first_run is not None
408+
inner = octobot_process_functional_shared._recall_inner_from_dsl_action(first_run)
409+
state = first_poll.dump()
410+
if not (inner and inner.get("init_state_ok") is True):
411+
while time.monotonic() < deadline:
412+
await asyncio.sleep(octobot_process_functional_shared.SLEEP_BETWEEN_JOB_POLLS_SEC)
413+
async with octobot_flow.jobs.AutomationJob(state, [], [], {}) as poll_job:
414+
await poll_job.run()
415+
octobot_process_functional_shared._assert_run_octobot_process_recall_scheduled_to_in_dump(
416+
poll_job.dump()
417+
)
418+
run_details = octobot_process_functional_shared._get_action_by_id(
419+
poll_job, octobot_process_functional_shared.ACTION_ID_RUN_OCTOBOT
420+
)
421+
assert run_details is not None
422+
inner = octobot_process_functional_shared._recall_inner_from_dsl_action(run_details)
423+
if inner and inner.get("init_state_ok") is True:
424+
state = poll_job.dump()
425+
break
426+
state = poll_job.dump()
427+
else:
428+
pytest.fail(
429+
f"OctoBot did not become ready (init_state_ok) within "
430+
f"{octobot_process_functional_shared.GLOBAL_START_TIMEOUT_SEC}s"
431+
)
432+
433+
assert inner is not None
434+
assert inner.get("pid"), "expected child pid in ensure state"
435+
assert popen_calls["count"] >= 1
436+
child_pid = int(inner["pid"])
437+
assert process_util.pid_is_running(child_pid)
438+
439+
user_root = pathlib.Path(inner["user_root"])
440+
assert inner.get("profile_id") == "non-trading"
441+
root_cfg = json.loads((user_root / common_constants.CONFIG_FILE).read_text(encoding="utf-8"))
442+
exchange_cfg = root_cfg[common_constants.CONFIG_EXCHANGES][
443+
octobot_process_functional_shared.EXCHANGE_BINANCEUS
444+
]
445+
exchange_auth_entry = exchange_auth[0]
446+
stored_api_key = exchange_cfg[common_constants.CONFIG_EXCHANGE_KEY]
447+
stored_api_secret = exchange_cfg[common_constants.CONFIG_EXCHANGE_SECRET]
448+
assert configuration_module.decrypt(stored_api_key) == exchange_auth_entry["api_key"]
449+
assert configuration_module.decrypt(stored_api_secret) == exchange_auth_entry["api_secret"]
450+
profile_json_path = (
451+
user_root
452+
/ common_constants.PROFILES_FOLDER
453+
/ "non-trading"
454+
/ common_constants.PROFILE_CONFIG_FILE
455+
)
456+
assert profile_json_path.is_file()
457+
458+
state_path = os.path.normpath(
459+
os.path.join(
460+
inner["user_root"],
461+
octobot_app_constants.PROCESS_BOT_STATE_FILE_NAME,
462+
)
463+
)
464+
assert os.path.isfile(state_path)
465+
with open(state_path, encoding="utf-8") as process_state_file:
466+
file_metadata_payload = json.load(process_state_file)
467+
process_metadata = process_bot_state_import.Metadata.from_dict(
468+
file_metadata_payload["metadata"]
469+
)
470+
assert isinstance(process_metadata, process_bot_state_import.Metadata)
471+
assert isinstance(process_metadata.updated_at, (int, float))
472+
assert isinstance(process_metadata.next_updated_at, (int, float))
473+
assert process_metadata.updated_at <= time.time()
474+
assert process_metadata.next_updated_at >= process_metadata.updated_at
475+
assert abs(
476+
(process_metadata.next_updated_at - process_metadata.updated_at)
477+
- octobot_process_functional_shared.EXPECTED_PROCESS_BOT_DUMP_INTERVAL_SEC
478+
) < 1.0
479+
480+
priority_actions = functionnal_tests.resolved_actions([stop_automation_action])
481+
async with octobot_flow.jobs.AutomationJob(state, priority_actions, [], {}) as stop_phase:
482+
await stop_phase.run()
483+
octobot_process_functional_shared._assert_run_octobot_process_recall_scheduled_to_in_dump(
484+
stop_phase.dump(),
485+
assert_delay_matches_waiting_time=False,
486+
)
487+
assert stop_phase.automation_state.automation.post_actions.stop_automation is True
488+
run_stopped = octobot_process_functional_shared._get_action_by_id(
489+
stop_phase, octobot_process_functional_shared.ACTION_ID_RUN_OCTOBOT
490+
)
491+
assert run_stopped is not None
492+
assert isinstance(run_stopped.result, dict)
493+
assert run_stopped.result.get("status") in ("stopped", "already_stopped")
494+
495+
process_deadline = time.monotonic() + octobot_process_functional_shared.CHILD_STOP_WAIT_SEC
496+
while time.monotonic() < process_deadline:
497+
if not process_util.pid_is_running(child_pid):
498+
break
499+
await asyncio.sleep(0.5)
500+
else:
501+
pytest.fail(
502+
f"expected child pid {child_pid} to be stopped after stop_automation/execution_stop "
503+
f"within {octobot_process_functional_shared.CHILD_STOP_WAIT_SEC}s"
504+
)
505+
506+
finally:
507+
if os.path.isdir(user_root_guess):
508+
shutil.rmtree(user_root_guess, ignore_errors=True)
509+
if os.path.isdir(log_folder_guess):
510+
shutil.rmtree(log_folder_guess, ignore_errors=True)

packages/node/octobot_node/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@
3737
USER_ACTION_WORKFLOW_MAX_ITERATION_RETRIES = int(os.getenv("USER_ACTION_WORKFLOW_MAX_ITERATION_RETRIES", 6))
3838
USER_ACTION_WORKFLOW_BACKOFF_RATE = float(os.getenv("USER_ACTION_WORKFLOW_BACKOFF_RATE", 2))
3939

40+
# run_octobot_process DSL recall interval and init-state timeout for child OctoBot spawns.
41+
RUN_OCTOBOT_PROCESS_WAITING_TIME_SECONDS = float(
42+
os.getenv("RUN_OCTOBOT_PROCESS_WAITING_TIME_SECONDS", 60.0)
43+
)
44+
RUN_OCTOBOT_PROCESS_PING_TIMEOUT_SECONDS = float(
45+
os.getenv("RUN_OCTOBOT_PROCESS_PING_TIMEOUT_SECONDS", 150.0)
46+
)
47+
4048
TASKS_ENCRYPTION_ENV_VARS = [
4149
"TASKS_SERVER_RSA_PRIVATE_KEY",
4250
"TASKS_SERVER_ECDSA_PRIVATE_KEY",

0 commit comments

Comments
 (0)