Skip to content

Commit 5739689

Browse files
diningPhilosopher64prabhakk-mw
authored andcommitted
Ties MATLAB state monitoring tasks to MATLAB lifecycle
1 parent 77f9832 commit 5739689

2 files changed

Lines changed: 130 additions & 36 deletions

File tree

matlab_proxy/app_state.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2020-2025 The MathWorks, Inc.
1+
# Copyright 2020-2026 The MathWorks, Inc.
22

33
import asyncio
44
import contextlib
@@ -136,9 +136,7 @@ def __init__(self, settings):
136136
loop = util.get_event_loop()
137137

138138
# matlab-proxy server related tasks which have the same lifetime as the server
139-
self.server_tasks = {
140-
"update_matlab_state": loop.create_task(self.__update_matlab_state())
141-
}
139+
self.server_tasks = {}
142140

143141
self.is_idle_timeout_enabled = (
144142
True if self.settings["mwi_idle_timeout"] else False
@@ -1404,6 +1402,9 @@ async def start_matlab(self, restart_matlab=False):
14041402
self.matlab_tasks["update_matlab_port"] = loop.create_task(
14051403
self.__update_matlab_port(self.MATLAB_PORT_CHECK_DELAY_IN_SECONDS)
14061404
)
1405+
self.matlab_tasks["update_matlab_state"] = loop.create_task(
1406+
self.__update_matlab_state()
1407+
)
14071408

14081409
async def __send_stop_request_to_matlab(self):
14091410
"""Private method to send a HTTP request to MATLAB to shutdown gracefully

tests/unit/test_app_state.py

Lines changed: 125 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
# Copyright 2023-2025 The MathWorks, Inc.
1+
# Copyright 2023-2026 The MathWorks, Inc.
22

33
import asyncio
44
import json
55
import os
66
from dataclasses import dataclass
77
from pathlib import Path
88
from typing import Optional
9+
from unittest.mock import AsyncMock, MagicMock
910

1011
import pytest
11-
from matlab_proxy import settings
1212

1313
from matlab_proxy import settings
1414
from matlab_proxy.app_state import AppState
@@ -18,13 +18,8 @@
1818
MatlabError,
1919
MatlabInstallError,
2020
)
21-
from matlab_proxy.constants import (
22-
CONNECTOR_SECUREPORT_FILENAME,
23-
USER_CODE_OUTPUT_FILE_NAME,
24-
)
25-
26-
from tests.unit.util import MockResponse
2721
from tests.unit.test_constants import CHECK_MATLAB_STATUS_INTERVAL, FIVE_MAX_TRIES
22+
from tests.unit.util import MockResponse
2823

2924

3025
@pytest.fixture
@@ -145,20 +140,32 @@ class Mock_xvfb:
145140
returncode: Optional[int]
146141
pid: Optional[int]
147142

143+
async def wait(self) -> int:
144+
return self.returncode
145+
146+
def terminate(self):
147+
"""Mock terminate method"""
148+
pass
149+
148150

149151
@dataclass(frozen=True)
150152
class Mock_matlab:
151153
"""An immutable dataclass representing a mocked MATLAB process"""
152154

153155
returncode: Optional[int]
154156
pid: Optional[int]
157+
stderr: MagicMock = MagicMock()
155158

156159
def is_running(self) -> bool:
157160
return self.returncode is None
158161

159-
def wait(self) -> int:
162+
async def wait(self) -> int:
160163
return self.returncode
161164

165+
def terminate(self):
166+
"""Mock terminate method"""
167+
pass
168+
162169

163170
@pytest.mark.parametrize(
164171
"licensing, expected",
@@ -547,6 +554,14 @@ async def test_requests_sent_by_matlab_proxy_have_headers(
547554
ok=True, payload={"messages": {"EvalResponse": [{"isError": None}]}}
548555
)
549556
mocked_req = mocker.patch("aiohttp.ClientSession.request", return_value=mock_resp)
557+
mock_xvfb = Mock_xvfb(None, 12345)
558+
mock_matlab = Mock_matlab(None, 12346)
559+
560+
app_state = app_state_with_token_auth_fixture
561+
562+
app_state.matlab_session_files["matlab_ready_file"] = (
563+
app_state.matlab_session_files["matlab_ready_file"].touch()
564+
)
550565

551566
# Patching to make _are_required_processes_ready() to return True
552567
mocker.patch.object(
@@ -560,23 +575,31 @@ async def test_requests_sent_by_matlab_proxy_have_headers(
560575
"get_matlab_state",
561576
return_value="up",
562577
)
563-
# Wait for _update_matlab_connector_status to run
564-
await asyncio.sleep(CHECK_MATLAB_STATUS_INTERVAL)
578+
579+
# Patching xvfb subprocess
580+
mocker.patch.object(
581+
AppState, "_AppState__start_xvfb_process", return_value=mock_xvfb
582+
)
583+
584+
# Patching xvfb subprocess
585+
mocker.patch.object(
586+
AppState, "_AppState__start_matlab_process", return_value=mock_matlab
587+
)
588+
589+
# start matlab
590+
await app_state.start_matlab()
591+
592+
# Wait for fake MATLAB to startup
593+
await asyncio.sleep(CHECK_MATLAB_STATUS_INTERVAL * 5)
565594

566595
# Act
567-
await app_state_with_token_auth_fixture._AppState__send_stop_request_to_matlab()
596+
# Send the stop request
597+
await app_state._AppState__send_stop_request_to_matlab()
568598

569599
# Assert
570-
571-
# 1 request from _update_matlab_connector_status() and another from
572-
# /stop_matlab request
573-
connector_status_request_headers = list(mocked_req.call_args_list)[0].kwargs[
574-
"headers"
575-
]
576-
send_stop_matlab_request_headers = list(mocked_req.call_args_list)[1].kwargs[
600+
send_stop_matlab_request_headers = list(mocked_req.call_args_list)[0].kwargs[
577601
"headers"
578602
]
579-
assert sample_token_headers_fixture == connector_status_request_headers
580603
assert sample_token_headers_fixture == send_stop_matlab_request_headers
581604

582605

@@ -711,7 +734,7 @@ async def test_detect_active_client_status_can_reset_active_client(app_state_fix
711734
)
712735
assert (
713736
app_state_fixture.active_client == None
714-
), f"Expected the active_client to be None"
737+
), "Expected the active_client to be None"
715738

716739

717740
@pytest.mark.parametrize(
@@ -815,21 +838,32 @@ async def test_decrement_timer_runs_out(sample_settings_fixture, mocker):
815838
idle_timeout = 1
816839
sample_settings_fixture["mwi_idle_timeout"] = idle_timeout
817840
app_state = AppState(settings=sample_settings_fixture)
818-
app_state.processes = {"matlab": None, "xvfb": None}
841+
mock_xvfb = Mock_xvfb(None, 12345)
842+
mock_matlab = Mock_matlab(None, 12346)
843+
819844
app_state.licensing = {"type": "existing_license"}
820845

821846
# mock util.get_event_loop() to return a new event_loop for the test to assert
822847
mock_loop = asyncio.new_event_loop()
823848
mocker.patch("matlab_proxy.app_state.util.get_event_loop", return_value=mock_loop)
849+
# Patching xvfb subprocess
850+
mocker.patch.object(
851+
AppState, "_AppState__start_xvfb_process", return_value=mock_xvfb
852+
)
853+
854+
# Patching xvfb subprocess
855+
mocker.patch.object(
856+
AppState, "_AppState__start_matlab_process", return_value=mock_matlab
857+
)
824858

825859
# Act
826860
# Wait for a little more time than idle_timeout to decrease flakiness of this test on different platforms.
827-
# MATLAB state changes from down -> starting -> up -> down (idle timer runs out)
861+
# MATLAB state is set to 'stopping' by stop_matlab() which is called once the decrement timer runs out.
828862
await asyncio.sleep(idle_timeout * FIVE_MAX_TRIES)
829863

830864
# Assert
831865
assert not mock_loop.is_running()
832-
assert app_state.get_matlab_state() == "down"
866+
assert app_state.get_matlab_state() == "stopping"
833867

834868
# Cleanup
835869
mock_loop.stop()
@@ -1009,12 +1043,12 @@ async def assert_matlab_state(app_state_fixture, expected_matlab_status, count):
10091043
@pytest.mark.parametrize(
10101044
"matlab_ready_file, expected_matlab_status",
10111045
[
1012-
(None, "down"),
1013-
(Path("dummy"), "starting"),
1046+
(None, "starting"),
1047+
(Path("dummy"), "up"),
10141048
],
10151049
ids=[
10161050
"no_matlab_ready_file_formed",
1017-
"no_matlab_ready_file_created",
1051+
"matlab_ready_file_created",
10181052
],
10191053
)
10201054
async def test_check_matlab_connector_status_auto_updates_based_on_matlab_ready_file(
@@ -1034,18 +1068,52 @@ async def test_check_matlab_connector_status_auto_updates_based_on_matlab_ready_
10341068
"_are_required_processes_ready",
10351069
return_value=True,
10361070
)
1071+
if matlab_ready_file:
1072+
matlab_ready_file.touch()
1073+
print("File eixts ", matlab_ready_file.exists())
1074+
10371075
app_state_fixture.matlab_session_files["matlab_ready_file"] = matlab_ready_file
10381076

1077+
mock_xvfb = Mock_xvfb(None, 12345)
1078+
mock_matlab = Mock_matlab(None, 12346)
1079+
1080+
# Patching xvfb subprocess
1081+
mocker.patch.object(
1082+
AppState, "_AppState__start_xvfb_process", return_value=mock_xvfb
1083+
)
1084+
1085+
# Patching xvfb subprocess
1086+
mocker.patch.object(
1087+
AppState, "_AppState__start_matlab_process", return_value=mock_matlab
1088+
)
1089+
1090+
# MATLAB state should be 'down' first
1091+
assert app_state_fixture.get_matlab_state() == "down"
1092+
1093+
mocker.patch.object(
1094+
AppState, "_AppState__matlab_stderr_reader_posix", new=AsyncMock()
1095+
)
1096+
mocker.patch.object(
1097+
AppState, "_AppState__track_embedded_connector_state", new=AsyncMock()
1098+
)
1099+
mocker.patch.object(AppState, "_AppState__update_matlab_port", new=AsyncMock())
1100+
mocker.patch(
1101+
"matlab_proxy.app_state.mwi.embedded_connector.request.get_state",
1102+
return_value="up",
1103+
)
1104+
10391105
# Act
1040-
# Nothing to act upon as the _update_matlab_state() is started automatically in the constructor.
1106+
await app_state_fixture.start_matlab()
1107+
1108+
if matlab_ready_file:
1109+
app_state_fixture.matlab_session_files["matlab_ready_file"].touch()
1110+
1111+
# Nothing to act upon as the _update_matlab_state() is started by start_matlab().
10411112
# Have to wait here for the atleast the same interval as the __update_matlab_state_based_on_endpoint_to_use()
1042-
# for the MATLAB status to update from 'down'
1113+
# for the MATLAB status to update from 'down' to 'up'
10431114

10441115
# Assert
1045-
# MATLAB state should be 'down' first
1046-
assert app_state_fixture.get_matlab_state() == "down"
10471116
await asyncio.sleep(CHECK_MATLAB_STATUS_INTERVAL)
1048-
10491117
await assert_matlab_state(app_state_fixture, expected_matlab_status, FIVE_MAX_TRIES)
10501118

10511119

@@ -1062,6 +1130,18 @@ async def test_update_matlab_state_switches_to_busy_endpoint(
10621130
"""
10631131
# Arrange
10641132
# Setup mocks for the first ping request to be successful
1133+
mock_xvfb = Mock_xvfb(None, 12345)
1134+
mock_matlab = Mock_matlab(None, 12346)
1135+
1136+
# Patching xvfb subprocess
1137+
mocker.patch.object(
1138+
AppState, "_AppState__start_xvfb_process", return_value=mock_xvfb
1139+
)
1140+
1141+
# Patching xvfb subprocess
1142+
mocker.patch.object(
1143+
AppState, "_AppState__start_matlab_process", return_value=mock_matlab
1144+
)
10651145
mocker.patch.object(
10661146
AppState,
10671147
"_are_required_processes_ready",
@@ -1071,6 +1151,19 @@ async def test_update_matlab_state_switches_to_busy_endpoint(
10711151
"matlab_proxy.app_state.mwi.embedded_connector.request.get_state",
10721152
return_value="up",
10731153
)
1154+
mocker.patch.object(
1155+
AppState, "_AppState__matlab_stderr_reader_posix", new=AsyncMock()
1156+
)
1157+
mocker.patch.object(
1158+
AppState, "_AppState__track_embedded_connector_state", new=AsyncMock()
1159+
)
1160+
mocker.patch.object(AppState, "_AppState__update_matlab_port", new=AsyncMock())
1161+
1162+
# MATLAB state should be 'down' first
1163+
assert app_state_fixture.get_matlab_state() == "down"
1164+
1165+
await app_state_fixture.start_matlab()
1166+
10741167
tmp_file = tmp_path / Path("dummy")
10751168
tmp_file.touch()
10761169
app_state_fixture.matlab_session_files["matlab_ready_file"] = tmp_file

0 commit comments

Comments
 (0)