matlab-proxy 0.5.3__py3-none-any.whl → 0.30.1__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 (104) hide show
  1. matlab_proxy/app.py +578 -205
  2. matlab_proxy/app_state.py +1061 -431
  3. matlab_proxy/constants.py +37 -0
  4. matlab_proxy/default_configuration.py +39 -4
  5. matlab_proxy/devel.py +18 -22
  6. matlab_proxy/gui/index.html +20 -1
  7. matlab_proxy/gui/static/css/index.BedVwcEg.css +10 -0
  8. matlab_proxy/gui/static/js/index.pQwV1obF.js +64 -0
  9. matlab_proxy/gui/static/media/MATLAB-env-blur.NupTbPv_.png +0 -0
  10. matlab_proxy/matlab/evaluateUserMatlabCode.m +51 -0
  11. matlab_proxy/matlab/startup.m +3 -28
  12. matlab_proxy/settings.py +543 -112
  13. matlab_proxy/util/__init__.py +187 -59
  14. matlab_proxy/util/cookie_jar.py +72 -0
  15. matlab_proxy/util/event_loop.py +28 -10
  16. matlab_proxy/util/list_servers.py +71 -26
  17. matlab_proxy/util/mw.py +16 -15
  18. matlab_proxy/util/mwi/download.py +136 -0
  19. matlab_proxy/util/mwi/embedded_connector/__init__.py +1 -1
  20. matlab_proxy/util/mwi/embedded_connector/helpers.py +12 -4
  21. matlab_proxy/util/mwi/embedded_connector/request.py +78 -12
  22. matlab_proxy/util/mwi/environment_variables.py +120 -27
  23. matlab_proxy/util/mwi/exceptions.py +63 -9
  24. matlab_proxy/util/mwi/logger.py +141 -27
  25. matlab_proxy/util/mwi/session_name.py +28 -0
  26. matlab_proxy/util/mwi/token_auth.py +264 -121
  27. matlab_proxy/util/mwi/validators.py +231 -88
  28. matlab_proxy/util/system.py +9 -0
  29. matlab_proxy/util/windows.py +32 -6
  30. {matlab_proxy-0.5.3.dist-info → matlab_proxy-0.30.1.dist-info}/METADATA +94 -49
  31. matlab_proxy-0.30.1.dist-info/RECORD +88 -0
  32. {matlab_proxy-0.5.3.dist-info → matlab_proxy-0.30.1.dist-info}/WHEEL +1 -2
  33. {matlab_proxy-0.5.3.dist-info → matlab_proxy-0.30.1.dist-info}/entry_points.txt +1 -1
  34. matlab_proxy_manager/README.md +85 -0
  35. matlab_proxy_manager/__init__.py +6 -0
  36. matlab_proxy_manager/lib/README.md +53 -0
  37. matlab_proxy_manager/lib/__init__.py +1 -0
  38. matlab_proxy_manager/lib/api.py +419 -0
  39. matlab_proxy_manager/storage/README.md +54 -0
  40. matlab_proxy_manager/storage/__init__.py +1 -0
  41. matlab_proxy_manager/storage/file_repository.py +144 -0
  42. matlab_proxy_manager/storage/interface.py +62 -0
  43. matlab_proxy_manager/storage/server.py +172 -0
  44. matlab_proxy_manager/utils/__init__.py +1 -0
  45. matlab_proxy_manager/utils/auth.py +77 -0
  46. matlab_proxy_manager/utils/constants.py +8 -0
  47. matlab_proxy_manager/utils/decorators.py +37 -0
  48. matlab_proxy_manager/utils/environment_variables.py +51 -0
  49. matlab_proxy_manager/utils/exceptions.py +45 -0
  50. matlab_proxy_manager/utils/helpers.py +314 -0
  51. matlab_proxy_manager/utils/logger.py +76 -0
  52. matlab_proxy_manager/web/README.md +37 -0
  53. matlab_proxy_manager/web/__init__.py +1 -0
  54. matlab_proxy_manager/web/app.py +536 -0
  55. matlab_proxy_manager/web/monitor.py +45 -0
  56. matlab_proxy_manager/web/watcher.py +65 -0
  57. matlab_proxy/gui/asset-manifest.json +0 -23
  58. matlab_proxy/gui/authorization.html +0 -115
  59. matlab_proxy/gui/bootstrap.3.4.1.min.css +0 -6
  60. matlab_proxy/gui/navbar.css +0 -8
  61. matlab_proxy/gui/signin.css +0 -42
  62. matlab_proxy/gui/static/css/main.d890078a.chunk.css +0 -13
  63. matlab_proxy/gui/static/css/main.d890078a.chunk.css.map +0 -1
  64. matlab_proxy/gui/static/js/2.13be6544.chunk.js +0 -3
  65. matlab_proxy/gui/static/js/2.13be6544.chunk.js.LICENSE.txt +0 -59
  66. matlab_proxy/gui/static/js/2.13be6544.chunk.js.map +0 -1
  67. matlab_proxy/gui/static/js/main.c311d854.chunk.js +0 -2
  68. matlab_proxy/gui/static/js/main.c311d854.chunk.js.map +0 -1
  69. matlab_proxy/gui/static/js/runtime-main.f70e4d5f.js +0 -2
  70. matlab_proxy/gui/static/js/runtime-main.f70e4d5f.js.map +0 -1
  71. matlab_proxy/gui/static/media/arrow.0c2968b9.svg +0 -4
  72. matlab_proxy/gui/static/media/feedback.6e8d50eb.svg +0 -1
  73. matlab_proxy/gui/static/media/gripper.9defbc5e.svg +0 -1
  74. matlab_proxy/gui/static/media/help.15e5bfab.svg +0 -1
  75. matlab_proxy/gui/static/media/ico-header-contact-hover.0958c442.svg +0 -17
  76. matlab_proxy/gui/static/media/ico-header-contact.ae9169c8.svg +0 -17
  77. matlab_proxy/gui/static/media/restart.7987508a.svg +0 -1
  78. matlab_proxy/gui/static/media/sign-out.08356b67.svg +0 -1
  79. matlab_proxy/gui/static/media/start.50c4596f.svg +0 -1
  80. matlab_proxy/gui/static/media/stop.30c9a9ab.svg +0 -1
  81. matlab_proxy/gui/static/media/terminate.7ea1363e.svg +0 -1
  82. matlab_proxy/gui/token.html +0 -123
  83. matlab_proxy-0.5.3.dist-info/RECORD +0 -84
  84. matlab_proxy-0.5.3.dist-info/top_level.txt +0 -1
  85. /matlab_proxy/gui/static/media/{glyphicons-halflings-regular.82b1212e.woff → glyphicons-halflings-regular.BKjkU69z.woff} +0 -0
  86. /matlab_proxy/gui/static/media/{glyphicons-halflings-regular.5be1347c.eot → glyphicons-halflings-regular.BUJKDMgK.eot} +0 -0
  87. /matlab_proxy/gui/static/media/{glyphicons-halflings-regular.060b2710.svg → glyphicons-halflings-regular.CSehLiBc.svg} +0 -0
  88. /matlab_proxy/gui/static/media/{glyphicons-halflings-regular.4692b9ec.ttf → glyphicons-halflings-regular.DrwTMapi.ttf} +0 -0
  89. /matlab_proxy/gui/static/media/{glyphicons-halflings-regular.be810be3.woff2 → glyphicons-halflings-regular.DzqM6ju8.woff2} +0 -0
  90. /matlab_proxy/gui/static/media/{ico-header-account-hover.89438e91.svg → ico-header-account-hover.-jQHo6Wx.svg} +0 -0
  91. /matlab_proxy/gui/static/media/{ico-header-account.86b10d7b.svg → ico-header-account.CJCFoo5a.svg} +0 -0
  92. /matlab_proxy/gui/static/media/{ico-sprite.cbdb66c0.png → ico-sprite.DXGLgzq9.png} +0 -0
  93. /matlab_proxy/gui/static/media/{mathworks-eps.4d20e0ee.ttf → mathworks-eps.CGNQALa9.ttf} +0 -0
  94. /matlab_proxy/gui/static/media/{mathworks-eps.df1428df.svg → mathworks-eps.DrkCtQtG.svg} +0 -0
  95. /matlab_proxy/gui/static/media/{mathworks-eps.e5c41e84.woff → mathworks-eps.Ds7lQbql.woff} +0 -0
  96. /matlab_proxy/gui/static/media/{mathworks-pictograms.3fc6513a.woff → mathworks-pictograms.BdqxEfBR.woff} +0 -0
  97. /matlab_proxy/gui/static/media/{mathworks-pictograms.f6f087b0.svg → mathworks-pictograms.CCLweoD4.svg} +0 -0
  98. /matlab_proxy/gui/static/media/{mathworks-pictograms.6e128c0e.ttf → mathworks-pictograms.DZhFdRSm.ttf} +0 -0
  99. /matlab_proxy/gui/static/media/{mathworks.80a3218e.svg → mathworks.C-qsbhDy.svg} +0 -0
  100. /matlab_proxy/gui/static/media/{mathworks.c422935b.ttf → mathworks.Ceplx86V.ttf} +0 -0
  101. /matlab_proxy/gui/static/media/{mathworks.37a563ef.woff → mathworks.D08X1Vp8.woff} +0 -0
  102. /matlab_proxy/gui/static/media/{trigger-error.3f1c4ef2.svg → trigger-error.QEdsGL-m.svg} +0 -0
  103. /matlab_proxy/gui/static/media/{trigger-ok.7b9c238b.svg → trigger-ok.Dzg8OIrk.svg} +0 -0
  104. {matlab_proxy-0.5.3.dist-info → matlab_proxy-0.30.1.dist-info/licenses}/LICENSE.md +0 -0
matlab_proxy/app_state.py CHANGED
@@ -1,30 +1,39 @@
1
- # Copyright (c) 2020-2022 The MathWorks, Inc.
1
+ # Copyright 2020-2026 The MathWorks, Inc.
2
2
 
3
3
  import asyncio
4
- import errno
4
+ import contextlib
5
5
  import json
6
6
  import logging
7
7
  import os
8
- import socket
9
8
  import sys
10
9
  import time
10
+ import uuid
11
11
  from collections import deque
12
12
  from datetime import datetime, timedelta, timezone
13
-
14
- import aiohttp
13
+ from typing import Callable, Final, Optional
15
14
 
16
15
  from matlab_proxy import util
16
+ from matlab_proxy.constants import (
17
+ CHECK_MATLAB_STATUS_INTERVAL_SECONDS,
18
+ CONNECTOR_SECUREPORT_FILENAME,
19
+ IS_CONCURRENCY_CHECK_ENABLED,
20
+ MATLAB_LOGS_FILE_NAME,
21
+ USER_CODE_OUTPUT_FILE_NAME,
22
+ )
23
+ from matlab_proxy.settings import get_process_startup_timeout
17
24
  from matlab_proxy.util import mw, mwi, system, windows
18
25
  from matlab_proxy.util.mwi import environment_variables as mwi_env
19
26
  from matlab_proxy.util.mwi import token_auth
20
27
  from matlab_proxy.util.mwi.exceptions import (
21
28
  EmbeddedConnectorError,
22
29
  EntitlementError,
23
- InternalError,
30
+ FatalError,
24
31
  LicensingError,
25
32
  MatlabError,
26
33
  MatlabInstallError,
27
34
  OnlineLicensingError,
35
+ UIVisibleFatalError,
36
+ WindowManagerError,
28
37
  XvfbError,
29
38
  log_error,
30
39
  )
@@ -32,11 +41,29 @@ from matlab_proxy.util.mwi.exceptions import (
32
41
  logger = mwi.logger.get()
33
42
 
34
43
 
44
+ def _get_server_urls(server_url: str, mwi_auth_token_str: str) -> list[str]:
45
+ """Returns list of server URLs including user supplied hostname, if any."""
46
+ # By default mwi_server_url usually points to 0.0.0.0 as the hostname, but this does not work well
47
+ # on some browsers. Specifically on Safari (MacOS), hence the replace op with localhost.
48
+ server_urls_with_auth_token = [
49
+ f'{server_url.replace("0.0.0.0", "localhost")}{mwi_auth_token_str}'
50
+ ]
51
+ if user_supplied_hostname := os.getenv(mwi_env.get_env_name_app_host(), "").strip():
52
+ server_urls_with_auth_token.append(
53
+ f'{server_urls_with_auth_token[0].replace("localhost", user_supplied_hostname)}'
54
+ )
55
+
56
+ return server_urls_with_auth_token
57
+
58
+
35
59
  class AppState:
36
60
  """A Class which represents the state of the App.
37
61
  This class handles state of MATLAB, MATLAB Licensing and Xvfb.
38
62
  """
39
63
 
64
+ # Constants that are applicable to AppState class
65
+ MATLAB_PORT_CHECK_DELAY_IN_SECONDS: Final[int] = 1
66
+
40
67
  def __init__(self, settings):
41
68
  """Parameterized constructor for the AppState class.
42
69
  Initializes member variables and checks for an existing MATLAB installation.
@@ -47,17 +74,17 @@ class AppState:
47
74
  self.settings = settings
48
75
  self.processes = {"matlab": None, "xvfb": None}
49
76
 
50
- # The port on which MATLAB(launched by this matlab-proxy process) starts on.
77
+ # Timeout for processes started by matlab-proxy
78
+ self.PROCESS_TIMEOUT = get_process_startup_timeout()
79
+
80
+ # The port on which MATLAB(started by this matlab-proxy process) starts on.
51
81
  self.matlab_port = None
52
82
 
53
- # The directory in which the instance of MATLAB (launched by this matlab-proxy process) will write logs to.
83
+ # The directory in which the instance of MATLAB (started by this matlab-proxy process) will write logs to.
54
84
  self.mwi_logs_dir = None
55
85
 
56
86
  # Dictionary of all files used to manage the MATLAB session.
57
87
  self.matlab_session_files = {
58
- # The file created by this instance of matlab-proxy to signal to other matlab-proxy processes
59
- # that this self.matlab_port will be used by this instance.
60
- "mwi_proxy_lock_file": None,
61
88
  # The file created and written by MATLAB's Embedded connector to signal readiness.
62
89
  "matlab_ready_file": None,
63
90
  }
@@ -69,51 +96,168 @@ class AppState:
69
96
  }
70
97
 
71
98
  self.licensing = None
72
- self.tasks = {}
99
+ # MATLAB process related tasks which have the same lifetime as MATLAB
100
+ self.matlab_tasks = {}
73
101
  self.logs = {
74
102
  "matlab": deque(maxlen=200),
75
103
  }
76
- self.error = None
77
- # Start in an error state if MATLAB is not present
78
- if not self.is_matlab_present():
79
- self.error = MatlabInstallError("'matlab' executable not found in PATH")
80
- logger.error("'matlab' executable not found in PATH")
81
- return
82
104
 
83
- def __get_cached_licensing_file(self):
84
- """Get the cached licensing file
105
+ # Initialize with the error state from the initialization of settings
106
+ self.error = settings["error"]
107
+ self.warnings = settings["warnings"]
108
+
109
+ # Keep track of when the Embedded connector starts.
110
+ # Would be initialized appropriately by get_embedded_connector_state() task.
111
+ self.embedded_connector_start_time = None
112
+
113
+ # Keep track of the state of the Embedded Connector.
114
+ # If there is some problem with starting the Embedded Connector(say an issue with licensing),
115
+ # the state of MATLAB process in app_state will continue to be in a 'starting' indefinitely.
116
+ # This variable can be either "up" or "down"
117
+ self.embedded_connector_state = "down"
118
+
119
+ # Specific to concurrent session and is used to track the active client/s that are currently
120
+ # connected to the backend
121
+ self.active_client = None
122
+
123
+ # Used to detect whether the active client is actively sending out request or is inactive
124
+ self.active_client_request_detected = False
125
+
126
+ # Initialize matlab with 'down' state.
127
+ # Should only be updated/accessed via setter/getter methods.
128
+ self.__matlab_state = "down"
129
+
130
+ # Initialize busy state as None as matlab state is initialized as 'down'.
131
+ self.matlab_busy_state = None
132
+
133
+ # Lock to be used before modifying MATLAB state
134
+ self.matlab_state_updater_lock = util.TrackingLock(purpose="MATLAB state")
135
+
136
+ loop = util.get_event_loop()
137
+
138
+ # matlab-proxy server related tasks which have the same lifetime as the server
139
+ self.server_tasks = {}
140
+
141
+ self.is_idle_timeout_enabled = (
142
+ True if self.settings["mwi_idle_timeout"] else False
143
+ )
144
+
145
+ if self.is_idle_timeout_enabled:
146
+ self.__initial_idle_timeout = self.__remaining_idle_timeout = self.settings[
147
+ "mwi_idle_timeout"
148
+ ]
149
+ # Lock to be used before updating IDLE timer.
150
+ self.idle_timeout_lock = util.TrackingLock(purpose="MATLAB IDLE timer")
151
+ self.server_tasks["decrement_idle_timer"] = loop.create_task(
152
+ self.__decrement_idle_timer()
153
+ )
154
+
155
+ # Flag to track if matlab-proxy is in the process of shutting down
156
+ self.is_shutting_down: bool = False
157
+
158
+ def set_remaining_idle_timeout(self, new_timeout):
159
+ """Sets the remaining IDLE timeout after the validating checks.
160
+
161
+ Args:
162
+ new_timeout (int): New timeout value
163
+ """
164
+ caller = util.get_caller_name()
165
+ if self.idle_timeout_lock.validate_lock_for_caller(caller):
166
+ self.__remaining_idle_timeout = new_timeout
167
+ logger.debug(
168
+ f"'{util.get_caller_name()}()' function acquired the lock to update IDLE timer"
169
+ )
170
+
171
+ else:
172
+ # NOTE: This code branch should only ever be hit during development. We exit to enforce proper usage of this function during development time.
173
+ sys.exit(1)
174
+
175
+ def get_remaining_idle_timeout(self):
176
+ """Returns the remaining IDLE timeout after which matlab-proxy will shutdown
177
+
178
+ Returns:
179
+ int: Remaining IDLE timeout
180
+ """
181
+
182
+ # Lock is not required when reading __idle_timeout_left as the value maybe atmost 1 second old.
183
+ # Additionally, having a lock for the getter will increase the latency for the /get_status requests coming in.
184
+ return self.__remaining_idle_timeout
185
+
186
+ async def reset_timer(self):
187
+ """Resets the IDLE timer to its original value after acquiring a lock."""
188
+ await self.idle_timeout_lock.acquire()
189
+ self.set_remaining_idle_timeout(self.__initial_idle_timeout)
190
+ await self.idle_timeout_lock.release()
191
+
192
+ logger.debug(
193
+ f"IDLE timer has been reset to {self.get_remaining_idle_timeout()} seconds"
194
+ )
195
+
196
+ async def __decrement_idle_timer(self):
197
+ """Decrements the IDLE timer by 1 after acquiring a lock."""
198
+ this_task = "decrement_idle_timer"
199
+ logger.debug(f"{this_task}: Starting task...")
200
+
201
+ while self.get_remaining_idle_timeout() > 0:
202
+ # If MATLAB is either starting, stopping or busy, reset the IDLE timer.
203
+ if (
204
+ self.get_matlab_state() in ["starting", "stopping"]
205
+ or self.matlab_busy_state == "busy"
206
+ ):
207
+ await self.reset_timer()
208
+
209
+ else:
210
+ new_value = self.get_remaining_idle_timeout() - 1
211
+ await self.idle_timeout_lock.acquire()
212
+ self.set_remaining_idle_timeout(new_value)
213
+ await self.idle_timeout_lock.release()
214
+
215
+ logger.debug(
216
+ f"{this_task}: IDLE timer decremented to {new_value} seconds"
217
+ )
218
+
219
+ await asyncio.sleep(1)
220
+
221
+ logger.info("The IDLE timer for shutdown has run out...")
222
+ logger.info(f"Shutting down {self.settings['integration_name']}")
223
+ await self.stop_matlab()
224
+ loop = util.get_event_loop()
225
+ loop.stop()
226
+
227
+ def __get_cached_config_file(self):
228
+ """Get the cached config file
85
229
 
86
230
  Returns:
87
- Path : Path object to cached licensing file
231
+ Path : Path object to cached config file
88
232
  """
89
233
  return self.settings["matlab_config_file"]
90
234
 
91
- def __delete_cached_licensing_file(self):
92
- """Deletes the cached licensing file"""
235
+ def __delete_cached_config_file(self):
236
+ """Deletes the cached config file"""
93
237
  try:
94
- logger.info(f"Deleting any cached licensing files!")
95
- os.remove(self.__get_cached_licensing_file())
238
+ logger.debug("Deleting any cached config files!")
239
+ os.remove(self.__get_cached_config_file())
96
240
  except FileNotFoundError:
97
241
  # The file being absent is acceptable.
98
242
  pass
99
243
 
100
- def __reset_and_delete_cached_licensing(self):
101
- """Reset licensing variable of the class and removes the cached licensing file."""
102
- logger.info(f"Resetting cached licensing information...")
244
+ def __reset_and_delete_cached_config(self):
245
+ """Reset licensing variable of the class and removes the cached config file."""
246
+ logger.debug("Resetting cached config information...")
103
247
  self.licensing = None
104
- self.__delete_cached_licensing_file()
248
+ self.__delete_cached_config_file()
105
249
 
106
250
  async def __update_and_persist_licensing(self):
107
- """Update entitlements from mhlm servers and persist licensing
251
+ """Update entitlements from mhlm servers and persist config data
108
252
 
109
253
  Returns:
110
254
  Boolean: True when entitlements were updated and persisted successfully. False otherwise.
111
255
  """
112
256
  successful_update = await self.update_entitlements()
113
257
  if successful_update:
114
- self.persist_licensing()
258
+ self.persist_config_data()
115
259
  else:
116
- self.__reset_and_delete_cached_licensing()
260
+ self.__reset_and_delete_cached_config()
117
261
  return successful_update
118
262
 
119
263
  async def init_licensing(self):
@@ -128,30 +272,56 @@ class AppState:
128
272
  # Default value
129
273
  self.licensing = None
130
274
 
275
+ # If MWI_USE_EXISTING_LICENSE is set in environment, try starting MATLAB directly
276
+ if self.settings["mwi_use_existing_license"]:
277
+ self.licensing = {"type": "existing_license"}
278
+ logger.debug(
279
+ f"{mwi_env.get_env_name_mwi_use_existing_license()} variable set in environment"
280
+ )
281
+ logger.info(
282
+ "!!! Starting MATLAB without providing any additional licensing information. This requires MATLAB to have been activated on the machine from which its being started !!!"
283
+ )
284
+
285
+ # Delete old config info from cache to ensure its wiped out first before persisting new info.
286
+ self.__delete_cached_config_file()
287
+
131
288
  # NLM Connection String set in environment
132
- if self.settings["nlm_conn_str"] is not None:
133
- nlm_licensing_str = self.settings["nlm_conn_str"]
289
+ elif self.settings.get("nlm_conn_str", None) is not None:
290
+ nlm_licensing_str = self.settings.get("nlm_conn_str")
134
291
  logger.debug(f"Found NLM:[{nlm_licensing_str}] set in environment")
135
- logger.debug(f"Using NLM string to connect ... ")
292
+ logger.info(f"Using NLM:{nlm_licensing_str} to connect...")
136
293
  self.licensing = {
137
294
  "type": "nlm",
138
295
  "conn_str": nlm_licensing_str,
139
296
  }
140
- self.__delete_cached_licensing_file()
141
297
 
142
- # If NLM connection string is not present, then look for persistent LNU info
143
- elif self.__get_cached_licensing_file().exists():
144
- with open(self.__get_cached_licensing_file(), "r") as f:
298
+ # Delete old config info from cache to ensure its wiped out first before persisting new info.
299
+ self.__delete_cached_config_file()
300
+
301
+ # If NLM connection string is not present or if an existing license is not being used,
302
+ # then look for persistent LNU info
303
+ elif self.__get_cached_config_file().exists():
304
+ with open(self.__get_cached_config_file(), "r") as f:
145
305
  logger.debug("Found cached licensing information...")
146
306
  try:
147
- # Load can throw if the file is empty for some reason.
148
- licensing = json.loads(f.read())
307
+ # Load can throw if the file is empty or expected fields in the json object are missing.
308
+ cached_data = json.loads(f.read())
309
+ licensing = cached_data["licensing"]
310
+ matlab = cached_data["matlab"]
311
+
312
+ # If Matlab version could not be determined on startup and 'version' is available in
313
+ # cached config, update it.
314
+ if not self.settings["matlab_version"]:
315
+ self.settings["matlab_version"] = matlab["version"]
316
+
149
317
  if licensing["type"] == "nlm":
150
318
  # Note: Only NLM settings entered in browser were cached.
151
319
  self.licensing = {
152
320
  "type": "nlm",
153
321
  "conn_str": licensing["conn_str"],
154
322
  }
323
+ logger.debug("Using cached NLM licensing to start MATLAB")
324
+
155
325
  elif licensing["type"] == "mhlm":
156
326
  self.licensing = {
157
327
  "type": "mhlm",
@@ -177,65 +347,334 @@ class AppState:
177
347
  await self.__update_and_persist_licensing()
178
348
  )
179
349
  if successful_update:
180
- logger.debug("Successful re-use of cached information.")
350
+ logger.debug(
351
+ "Using cached Online Licensing to start MATLAB."
352
+ )
181
353
  else:
182
- self.__reset_and_delete_cached_licensing()
354
+ self.__reset_and_delete_cached_config()
355
+ elif licensing["type"] == "existing_license":
356
+ logger.debug("Using cached existing license to start MATLAB")
357
+ self.licensing = licensing
183
358
  else:
184
359
  # Somethings wrong, licensing is neither NLM or MHLM
185
- self.__reset_and_delete_cached_licensing()
186
- except Exception as e:
187
- self.__reset_and_delete_cached_licensing()
360
+ self.__reset_and_delete_cached_config()
361
+ except Exception:
362
+ self.__reset_and_delete_cached_config()
363
+
364
+ async def __update_matlab_state_based_on_connector_state(self):
365
+ """Updates MATLAB state based on the Embedded Connector state.
366
+ This function is meant to be called after the required processes are ready.
367
+ """
368
+ if self.embedded_connector_state == "down":
369
+ await self.matlab_state_updater_lock.acquire()
370
+ # Even if the embedded connector's status is 'down', we return matlab status as
371
+ # 'starting' because the MATLAB process itself has been created and matlab-proxy
372
+ # is waiting for the embedded connector to start serving content.
373
+
374
+ if (
375
+ self.embedded_connector_state == "down"
376
+ ): # Double check EC state is down before invoking set_matlab_state().
377
+ self.set_matlab_state("starting")
378
+
379
+ # Update time stamp when MATLAB state is "starting".
380
+ if not self.embedded_connector_start_time:
381
+ self.embedded_connector_start_time = time.time()
382
+
383
+ # Set matlab_status to "up" since embedded connector is up.
384
+ else:
385
+ await self.matlab_state_updater_lock.acquire()
386
+ # Double check EC state is up before invoking set_matlab_state().
387
+ if self.embedded_connector_state == "up":
388
+ self.set_matlab_state("up")
389
+ await self.matlab_state_updater_lock.release()
390
+
391
+ async def __update_matlab_state_using_ping_endpoint(self) -> None:
392
+ """Updates MATLAB and its busy state based on the response from PING endpoint"""
393
+ # matlab-proxy sends a request to itself to the endpoint: /messageservice/json/state
394
+ # which the server redirects to the matlab_view() function to handle (which then sends the request to EC)
395
+ headers = self._get_token_auth_headers()
396
+ self.embedded_connector_state = await mwi.embedded_connector.request.get_state(
397
+ mwi_server_url=self.settings["mwi_server_url"],
398
+ headers=headers,
399
+ )
400
+
401
+ await self.__update_matlab_state_based_on_connector_state()
402
+
403
+ # When using the 'ping' endpoint its not possible to determine the busy status
404
+ # of MATLAB, so default to busy until the switch is made to use the 'busy' status endpoint in __update_matlab_state task.
405
+ # If EC is down, set MATLAB busy status to None
406
+ self.matlab_busy_state = (
407
+ "busy" if self.embedded_connector_state == "up" else None
408
+ )
409
+
410
+ async def __update_matlab_state_using_busy_status_endpoint(self) -> None:
411
+ """Updates MATLAB and its busy state based on the response from 'ping' endpoint"""
412
+ # matlab-proxy sends a request to itself to the endpoint: /messageservice/json/state
413
+ # which the server redirects to the matlab_view() function to handle (which then sends the request to EC)
414
+ headers = self._get_token_auth_headers()
415
+ self.matlab_busy_state = await mwi.embedded_connector.request.get_busy_state(
416
+ mwi_server_url=self.settings["mwi_server_url"],
417
+ headers=headers,
418
+ )
419
+
420
+ self.embedded_connector_state = "down" if not self.matlab_busy_state else "up"
421
+ await self.__update_matlab_state_based_on_connector_state()
422
+
423
+ async def __update_matlab_state_based_on_endpoint_to_use(
424
+ self, matlab_endpoint_to_use: Callable[[], None]
425
+ ) -> None:
426
+ """Updates MATLAB state based on:
427
+ 1) If the required processes are ready
428
+ 2) The response from Embedded connector.
429
+
430
+ Args:
431
+ matlab_endpoint_to_use (Callable): Function reference used to updated MATLAB and its busy status.
432
+ """
433
+ this_task = "update_matlab_state"
434
+ # First check before acquiring the lock.
435
+ if not self._are_required_processes_ready():
436
+ await self.matlab_state_updater_lock.acquire()
437
+ if (
438
+ not self._are_required_processes_ready()
439
+ ): # Double check required processes are not ready before invoking set_matlab_state()
440
+ self.set_matlab_state("down")
441
+ logger.debug(f"{this_task}: Required processes are not ready yet")
442
+ await self.matlab_state_updater_lock.release()
443
+ # Double-checked locking: https://en.wikipedia.org/wiki/Double-checked_locking
444
+ # If the lock is acquired inside the if condition (without a second check), it would lead to intermediate states
445
+ # 'starting'(set by start_matlab) -> 'down' (set in the if condition above) -> 'up' (set by this function after matlab starts)
446
+
447
+ # If lock is acquired before the if condition, it would lead to large sections of code being under lock which in this case would be
448
+ # this entire function (and the functions calls within it).
449
+
450
+ else:
451
+ await self.matlab_state_updater_lock.release()
452
+ await (
453
+ self._update_matlab_state_based_on_ready_file_and_connector_status(
454
+ matlab_endpoint_to_use
455
+ )
456
+ )
457
+ logger.debug(
458
+ f"{this_task}: Required processes are ready, Embedded Connector status is '{self.get_matlab_state()}'"
459
+ )
460
+
461
+ else:
462
+ await self._update_matlab_state_based_on_ready_file_and_connector_status(
463
+ matlab_endpoint_to_use
464
+ )
465
+ logger.debug(
466
+ f"{this_task}: Required processes are ready, Embedded Connector status is '{self.get_matlab_state()}'"
467
+ )
468
+
469
+ await asyncio.sleep(CHECK_MATLAB_STATUS_INTERVAL_SECONDS)
470
+
471
+ async def __update_matlab_state(self) -> None:
472
+ """An indefinitely running asyncio task which determines the status of MATLAB to be down/starting/up."""
473
+ this_task = "update_matlab_state"
474
+ logger.debug(f"{this_task}: Starting task...")
475
+
476
+ # Start with using the ping endpoint to update matlab and its 'busy' state.
477
+ function_to_call = self.__update_matlab_state_using_ping_endpoint
478
+ logger.debug("Using the 'ping' endpoint to determine MATLAB state")
479
+
480
+ while True:
481
+ await self.__update_matlab_state_based_on_endpoint_to_use(function_to_call)
482
+
483
+ if self.get_matlab_state() == "up":
484
+ logger.debug(
485
+ "MATLAB is up. Checking if 'busy' status endpoint is available"
486
+ )
487
+
488
+ # MATLAB is up, now switch to 'busy' status endpoint to check if 'busy' status updates
489
+ # to a valid value.
490
+ function_to_call = self.__update_matlab_state_using_busy_status_endpoint
491
+ await self.__update_matlab_state_based_on_endpoint_to_use(
492
+ function_to_call
493
+ )
494
+
495
+ # If MATLAB 'busy' status is None even after MATLAB state is 'up', implies that the
496
+ # endpoint is not available. So, fall back to using ping endpoint.
497
+ if not self.matlab_busy_state:
498
+ function_to_call = self.__update_matlab_state_using_ping_endpoint
499
+ logger.debug(
500
+ "'busy' status endpoint returned an invalid response, falling back to using 'ping' endpoint to determine MATLAB state"
501
+ )
502
+ warning = f"{mwi_env.get_env_name_shutdown_on_idle_timeout()} environment variable is supported only for MATLAB versions R2021a or later"
503
+ logger.warning(warning)
504
+ self.warnings.append(warning)
505
+
506
+ else:
507
+ logger.debug(
508
+ "'busy' status endpoint returned a valid response, will continue using it for determining MATLAB and its 'busy' state"
509
+ )
510
+
511
+ break
512
+
513
+ # Continue to use the same endpoint determined above.
514
+ while True:
515
+ await self.__update_matlab_state_based_on_endpoint_to_use(function_to_call)
188
516
 
189
- async def get_matlab_state(self):
190
- """Determine the state of MATLAB to be down/starting/up.
517
+ def set_matlab_state(self, new_state) -> None:
518
+ """Updates MATLAB state. Will exit the matlab-proxy process if a lock is not acquired
519
+ before calling this function.
520
+
521
+ Args:
522
+ new_state (str): The new state of MATLAB
523
+ """
524
+ caller = util.get_caller_name()
525
+ if self.matlab_state_updater_lock.validate_lock_for_caller(caller):
526
+ self.__matlab_state = new_state
527
+ logger.debug(f"'{caller}()' function updated MATLAB state to '{new_state}'")
528
+
529
+ else:
530
+ # NOTE: This code branch should only ever be hit during development. We exit to enforce proper usage of this function during development time.
531
+ sys.exit(1)
532
+
533
+ def get_matlab_state(self) -> str:
534
+ """Returns the state of MATLAB to be down/starting/up.
191
535
 
192
536
  Returns:
193
537
  String: Status of MATLAB. Returns either up, down or starting.
194
538
  """
539
+ # Lock is not required when reading __matlab_state as the value maybe atmost 1 second old.
540
+ # Additionally, having a lock for the getter will increase the latency for the /get_status requests coming in.
541
+ return self.__matlab_state
195
542
 
196
- matlab = self.processes["matlab"]
197
- xvfb = self.processes["xvfb"]
543
+ async def stop_server_tasks(self):
544
+ """Stops all matlab-proxy server tasks"""
545
+ await util.cancel_tasks(self.server_tasks)
546
+
547
+ def _are_required_processes_ready(
548
+ self, matlab_process=None, xvfb_process=None
549
+ ) -> bool:
550
+ """Checks if the required platform specific processes are ready.
551
+
552
+ Args:
553
+ matlab_process (asyncio.subprocess.Process | psutil.Process, optional): MATLAB process. Defaults to None.
554
+ xvfb_process (asyncio.subprocess.Process, optional): Xvfb Process. Defaults to None.
555
+
556
+ Returns:
557
+ bool: Whether the required processes are ready or not.
558
+ """
559
+
560
+ # Update the processes to what is tracked in the instance's processes if a None is received
561
+ if matlab_process is None:
562
+ matlab_process = self.processes["matlab"]
563
+ if xvfb_process is None:
564
+ xvfb_process = self.processes["xvfb"]
198
565
 
199
566
  if system.is_linux():
200
- if xvfb is None or xvfb.returncode is not None:
201
- return "down"
567
+ # If Xvfb is on system PATH, check if it up and running.
568
+ if self.settings.get("is_xvfb_available", None) and (
569
+ xvfb_process is None or xvfb_process.returncode is not None
570
+ ):
571
+ logger.debug(
572
+ "Xvfb has not started"
573
+ if xvfb_process is None
574
+ else f"Xvfb exited with returncode:{xvfb_process.returncode}"
575
+ )
576
+ return False
202
577
 
203
- if matlab is None or matlab.returncode is not None:
204
- return "down"
578
+ if matlab_process is None or matlab_process.returncode is not None:
579
+ logger.debug(
580
+ "MATLAB has not started"
581
+ if matlab_process is None
582
+ else f"MATLAB exited with returncode:{matlab_process.returncode}"
583
+ )
584
+ return False
205
585
 
206
586
  elif system.is_mac():
207
- if matlab is None or matlab.returncode is not None:
208
- return "down"
587
+ if matlab_process is None or matlab_process.returncode is not None:
588
+ logger.debug(
589
+ "MATLAB has not started"
590
+ if matlab_process is None
591
+ else f"MATLAB exited with returncode:{matlab_process.returncode}"
592
+ )
593
+ return False
594
+
595
+ # For windows platform
209
596
  else:
210
- if matlab is None or not matlab.is_running():
211
- return "down"
212
-
213
- # If execution reaches this else block, it implies that:
214
- # 1) MATLAB process has started.
215
- # 2) Embedded connector has not started yet.
216
-
217
- # So, even if the embedded connector's status is 'down', we'll
218
- # return as 'starting' because the MATLAB process itself has been created
219
- # and matlab-proxy is waiting for the embedded connector to start serving content.
220
- status = await mwi.embedded_connector.request.get_state(
221
- self.settings["mwi_server_url"]
597
+ if matlab_process is None or not matlab_process.is_running():
598
+ logger.debug(
599
+ "MATLAB has not started"
600
+ if matlab_process is None
601
+ else f"MATLAB exited with returncode:{matlab_process.wait()}"
602
+ )
603
+ return False
604
+
605
+ return True
606
+
607
+ def _get_token_auth_headers(self) -> Optional[dict]:
608
+ """Returns token info as headers if authentication is enabled.
609
+
610
+ Returns:
611
+ [Dict | None]: Returns token authentication headers if any.
612
+ """
613
+ return (
614
+ {
615
+ self.settings["mwi_auth_token_name_for_http"]: self.settings[
616
+ "mwi_auth_token_hash"
617
+ ]
618
+ }
619
+ if self.settings["mwi_is_token_auth_enabled"]
620
+ else None
222
621
  )
223
- if status == "down":
224
- status = "starting"
225
- # Update time stamp when MATLAB state is "starting". Only for Windows systems
226
- # The variable self.starting_state_timestamp is created by the matlab_stderr_reader() task
227
- # and is updated here.
228
- if not system.is_posix() and not self.starting_state_timestamp:
229
- self.starting_state_timestamp = time.time()
230
622
 
231
- return status
623
+ async def _update_matlab_state_based_on_ready_file_and_connector_status(
624
+ self, func_to_update_matlab_state: Callable[[], None]
625
+ ) -> None:
626
+ """Updates MATLAB and its 'busy' state based on Embedded Connector status.
627
+
628
+ Args:
629
+ func_to_update_matlab_state(callable): Function which updates MATLAB and its 'busy' state.
630
+ """
631
+ # NOTE: Double-checked locking should be applied where set_matlab_state() is called within this function,
632
+ # as it is invoked frequently (from the __update_matlab_state or anywhere set_matlab_state() is invoked frequently)
633
+ matlab_ready_file = self.matlab_session_files.get("matlab_ready_file")
634
+
635
+ if not matlab_ready_file:
636
+ await self.matlab_state_updater_lock.acquire()
637
+
638
+ if (
639
+ not matlab_ready_file
640
+ ): # Double check that matlab_ready_file is truthy before invoking set_matlab_state()
641
+ self.set_matlab_state("down")
642
+ await self.matlab_state_updater_lock.release()
643
+ return
644
+
645
+ else:
646
+ await self.matlab_state_updater_lock.release()
647
+ return
648
+
649
+ # If the matlab_ready_file path is constructed and is not yet created by the embedded connector.
650
+ if matlab_ready_file and not matlab_ready_file.exists():
651
+ await self.matlab_state_updater_lock.acquire()
652
+ if (
653
+ matlab_ready_file and not matlab_ready_file.exists()
654
+ ): # Double check that matlab_ready_file is truthy and exists before invoking set_matlab_state()
655
+ self.set_matlab_state("starting")
656
+ await self.matlab_state_updater_lock.release()
657
+ return
658
+
659
+ else:
660
+ await self.matlab_state_updater_lock.release()
661
+ return
662
+
663
+ # Proceed to query the Embedded Connector about its state and update MATLAB and its 'busy' state.
664
+
665
+ await func_to_update_matlab_state()
232
666
 
233
667
  async def set_licensing_nlm(self, conn_str):
234
668
  """Set the licensing type to NLM and the connection string."""
235
669
 
236
670
  # TODO Validate connection string
237
671
  self.licensing = {"type": "nlm", "conn_str": conn_str}
238
- self.persist_licensing()
672
+ self.persist_config_data()
673
+
674
+ def set_licensing_existing_license(self):
675
+ """Set the licensing type to NLM and the connection string."""
676
+ self.licensing = {"type": "existing_license"}
677
+ self.persist_config_data()
239
678
 
240
679
  async def set_licensing_mhlm(
241
680
  self,
@@ -254,9 +693,7 @@ class AppState:
254
693
  entitlements (list, optional): Eligible Entitlements of the user. Defaults to [].
255
694
  entitlement_id (String, optional): ID of an entitlement. Defaults to None.
256
695
  """
257
-
258
696
  try:
259
-
260
697
  token_data = await mw.fetch_expand_token(
261
698
  self.settings["mwa_api_endpoint"], identity_token, source_id
262
699
  )
@@ -288,6 +725,10 @@ class AppState:
288
725
  }
289
726
  log_error(logger, e)
290
727
 
728
+ except UIVisibleFatalError as e:
729
+ self.error = e
730
+ log_error(logger, e)
731
+
291
732
  def unset_licensing(self):
292
733
  """Unset the licensing."""
293
734
 
@@ -303,12 +744,12 @@ class AppState:
303
744
  Returns:
304
745
  Boolean: True if MATLAB is Licensed. False otherwise.
305
746
  """
306
-
307
747
  if self.licensing is not None:
308
- if self.licensing["type"] == "nlm":
309
- if self.licensing["conn_str"] is not None:
748
+ logger.debug(f"Licensing type: {self.licensing.get('type')}")
749
+ if self.licensing.get("type") == "nlm":
750
+ if self.licensing.get("conn_str") is not None:
310
751
  return True
311
- elif self.licensing["type"] == "mhlm":
752
+ elif self.licensing.get("type") == "mhlm":
312
753
  if (
313
754
  self.licensing.get("identity_token") is not None
314
755
  and self.licensing.get("source_id") is not None
@@ -316,28 +757,21 @@ class AppState:
316
757
  and self.licensing.get("entitlement_id") is not None
317
758
  ):
318
759
  return True
760
+ elif self.licensing.get("type") == "existing_license":
761
+ return True
319
762
  return False
320
763
 
321
- def is_matlab_present(self):
322
- """Is MATLAB install accessible?
323
-
324
- Returns:
325
- Boolean: True if MATLAB is present in the system. False otherwise.
326
- """
327
-
328
- return self.settings["matlab_path"] is not None
329
-
330
764
  async def update_entitlements(self):
331
765
  """Speaks to MW and updates MHLM entitlements
332
766
 
333
767
  Raises:
334
- InternalError: When licensing is None or when licensing type is not MHLM.
768
+ FatalError: When licensing is None or when licensing type is not MHLM.
335
769
 
336
770
  Returns:
337
771
  Boolean: True if update was successful
338
772
  """
339
773
  if self.licensing is None or self.licensing["type"] != "mhlm":
340
- raise InternalError(
774
+ raise FatalError(
341
775
  "MHLM licensing must be configured to update entitlements!"
342
776
  )
343
777
 
@@ -356,10 +790,6 @@ class AppState:
356
790
  self.settings["matlab_version"],
357
791
  )
358
792
 
359
- except OnlineLicensingError as e:
360
- self.error = e
361
- log_error(logger, e)
362
- return False
363
793
  except EntitlementError as e:
364
794
  self.error = e
365
795
  log_error(logger, e)
@@ -373,39 +803,60 @@ class AppState:
373
803
  self.licensing["profile_id"] = None
374
804
  self.licensing["entitlements"] = []
375
805
  self.licensing["entitlement_id"] = None
806
+ # To ensure that any entitlement errors are displayed on the control panel,
807
+ # the function returns true. The cached license file only contains the license type
808
+ # and the user's email address. These two attributes are necessary for preventing
809
+ # the LicenseGatherer step from becoming stuck on the front-end side.
810
+ # Additionally, displaying the license type and user email address on the
811
+ # information panel makes it worthwhile to maintain these attributes in the state.
812
+ return True
813
+
814
+ except OnlineLicensingError as e:
815
+ self.error = e
816
+ log_error(logger, e)
817
+ return False
818
+
819
+ # Keeping base error class at the last to catch any uncaught licensing related issues
820
+ except OnlineLicensingError as e:
821
+ self.error = e
822
+ log_error(logger, e)
376
823
  return False
377
824
 
378
825
  self.licensing["entitlements"] = entitlements
379
826
 
380
- # If there is only one non-expired entitlement, set it as active
381
- # TODO Also, for now, set the first entitlement as active if there are multiple
382
- self.licensing["entitlement_id"] = entitlements[0]["id"]
827
+ # Auto-select the entitlement if only one entitlement is returned from MHLM
828
+ if len(entitlements) == 1:
829
+ self.licensing["entitlement_id"] = entitlements[0]["id"]
383
830
 
384
831
  # Successful update
385
832
  return True
386
833
 
387
- def persist_licensing(self):
388
- """Saves licensing information to file"""
834
+ # Set the entitlement information on app state as well as the cached file
835
+ async def update_user_selected_entitlement_info(self, entitlement_id):
836
+ self.licensing["entitlement_id"] = entitlement_id
837
+ logger.debug(f"Successfully set {entitlement_id} as the entitlement_id")
838
+ self.persist_config_data()
839
+
840
+ def persist_config_data(self):
841
+ """Saves config information to file"""
389
842
  if self.licensing is None:
390
- self.__delete_cached_licensing_file()
843
+ self.__delete_cached_config_file()
391
844
 
392
- elif self.licensing["type"] in ["mhlm", "nlm"]:
845
+ elif self.licensing["type"] in ["mhlm", "nlm", "existing_license"]:
393
846
  logger.debug("Saving licensing information...")
394
- cached_licensing_file = self.__get_cached_licensing_file()
395
- cached_licensing_file.parent.mkdir(parents=True, exist_ok=True)
396
- with open(cached_licensing_file, "w") as f:
397
- f.write(json.dumps(self.licensing))
398
-
399
- def prepare_lock_files_for_MATLAB_launch(self):
400
- """Finds and reserves a free port for MATLAB Embedded Connector in the allowed range.
401
- Creates the lock file to prevent any other matlab-proxy process to use the reserved port of this
402
- process.
847
+ cached_config_file = self.__get_cached_config_file()
848
+ cached_config_file.parent.mkdir(parents=True, exist_ok=True)
849
+ config = {
850
+ "licensing": self.licensing,
851
+ "matlab": {"version": self.settings["matlab_version"]},
852
+ }
853
+ with open(cached_config_file, "w") as f:
854
+ f.write(json.dumps(config))
403
855
 
404
- Raises:
405
- e: socket.error if the exception raised is other than port already occupied.
406
- """
856
+ def create_logs_dir_for_MATLAB(self):
857
+ """Creates the root folder where MATLAB writes the ready file and updates attibutes on self."""
407
858
 
408
- # NOTE It is not guranteed that the port will remain free!
859
+ # NOTE It is not guaranteed that the port will remain free!
409
860
  # FIXME Because of https://github.com/http-party/node-http-proxy/issues/1342 the
410
861
  # node application in development mode always uses port 31515 to bypass the
411
862
  # reverse proxy. Once this is addressed, remove this special case.
@@ -415,78 +866,34 @@ class AppState:
415
866
  ):
416
867
  return 31515
417
868
  else:
869
+ mwi_logs_root_dir = self.settings["mwi_logs_root_dir"]
870
+ # Use the app_port number to identify the server as that is user visible
871
+ mwi_logs_dir = mwi_logs_root_dir / str(self.settings["app_port"])
418
872
 
419
- # TODO If MATLAB Connector is enhanced to allow any port, then the
420
- # following can be used to get an unused port instead of the for loop and
421
- # try-except.
422
- # s.bind(("", 0))
423
- # self.matlab_port = s.getsockname()[1]
424
-
425
- for port in mw.range_matlab_connector_ports():
426
- try:
427
- s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
428
- s.bind(("", port))
429
-
430
- mwi_logs_root_dir = self.settings["mwi_logs_root_dir"]
431
-
432
- # The mwi_proxy.lock file indicates to any other matlab-proxy processes
433
- # that this self.matlab_port number is taken up by this process.
434
- mwi_proxy_lock_file = mwi_logs_root_dir / (
435
- self.settings["mwi_proxy_lock_file_name"] + "." + str(port)
436
- )
437
-
438
- # Check if the mwi_proxy_lock_file exists.
439
- # Implies there was a competing matlab-proxy process which found the same port before this process
440
- if mwi_proxy_lock_file.exists():
441
- logger.debug(
442
- f"Skipping port number {port} for MATLAB as lock file already exists at {mwi_proxy_lock_file}"
443
- )
444
- s.close()
873
+ # Create a folder to hold the matlab_ready_file that will be created by MATLAB to signal readiness
874
+ # This is the same folder to which MATLAB will write logs to.
875
+ mwi_logs_dir.mkdir(parents=True, exist_ok=True)
445
876
 
446
- else:
447
- # Use the app_port number to identify the server as that is user visible
448
- mwi_logs_dir = mwi_logs_root_dir / str(
449
- self.settings["app_port"]
450
- )
877
+ # Created by MATLAB when it is ready to service requests
878
+ matlab_ready_file = mwi_logs_dir / CONNECTOR_SECUREPORT_FILENAME
451
879
 
452
- # Create a folder to hold the matlab_ready_file that will be created by MATLAB to signal readiness.
453
- # This is the same folder to which MATLAB will write logs to.
454
- mwi_logs_dir.mkdir(parents=True, exist_ok=True)
880
+ # Update member variables of AppState class
881
+ self.mwi_logs_dir = mwi_logs_dir
882
+ self.matlab_session_files["matlab_ready_file"] = matlab_ready_file
455
883
 
456
- # Create the lock file first to minimize the critical section.
457
- mwi_proxy_lock_file.touch()
458
- logger.info(
459
- f"Communicating with MATLAB on port:{port}, lock file: {mwi_proxy_lock_file}"
460
- )
884
+ logger.debug(f"matlab_session_files:{self.matlab_session_files}")
461
885
 
462
- # Created by MATLAB when it is ready to service requests.
463
- matlab_ready_file = mwi_logs_dir / "connector.securePort"
464
-
465
- # Update member variables of AppState class
466
- # Store the port number on which MATLAB will be launched for this matlab-proxy process.
467
- self.matlab_port = port
468
- self.mwi_logs_dir = mwi_logs_dir
469
- self.matlab_session_files[
470
- "mwi_proxy_lock_file"
471
- ] = mwi_proxy_lock_file
472
- self.matlab_session_files[
473
- "matlab_ready_file"
474
- ] = matlab_ready_file
475
- s.close()
476
-
477
- logger.debug(
478
- f"matlab_session_files:{self.matlab_session_files}"
479
- )
480
- return
481
-
482
- # For windows container's (when testing in github workflows) PermissionError and in linux, OSError is
483
- # thrown when trying to bind a used port from a previous test instead of the expected socket.error
484
- except (OSError, PermissionError) as e:
485
- pass
486
-
487
- except socket.error as e:
488
- if e.errno != errno.EADDRINUSE:
489
- raise e
886
+ # check if the user has provided any code or not
887
+ if self.settings.get("has_custom_code_to_execute"):
888
+ # Keep a reference to the user code output file in the matlab_session_files for cleanup
889
+ user_code_output_file = mwi_logs_dir / USER_CODE_OUTPUT_FILE_NAME
890
+ self.matlab_session_files["startup_code_output_file"] = (
891
+ user_code_output_file
892
+ )
893
+ logger.info(
894
+ f"The results of executing MWI_MATLAB_STARTUP_SCRIPT are stored at: {user_code_output_file} "
895
+ )
896
+ return
490
897
 
491
898
  def create_server_info_file(self):
492
899
  mwi_logs_root_dir = self.settings["mwi_logs_root_dir"]
@@ -498,40 +905,46 @@ class AppState:
498
905
 
499
906
  mwi_server_info_file = mwi_logs_dir / "mwi_server.info"
500
907
  mwi_auth_token_str = token_auth.get_mwi_auth_token_access_str(self.settings)
501
- with open(mwi_server_info_file, "w") as fh:
502
- fh.write(self.settings["mwi_server_url"] + mwi_auth_token_str + "\n")
908
+ with open(mwi_server_info_file, "w", encoding="utf-8") as fh:
909
+ fh.write(
910
+ self.settings["mwi_server_url"]
911
+ + mwi_auth_token_str
912
+ + "\n"
913
+ + self.settings["browser_title"]
914
+ + "\n"
915
+ )
503
916
  self.mwi_server_session_files["mwi_server_info_file"] = mwi_server_info_file
504
917
  logger.debug(f"Server info stored into: {mwi_server_info_file}")
505
918
 
506
- logger.info(
507
- util.prettify(
508
- boundary_filler="=",
509
- text_arr=[
510
- f"MATLAB can be accessed at:",
511
- self.settings["mwi_server_url"] + mwi_auth_token_str,
512
- ],
513
- )
919
+ mwi.logger.log_startup_info(
920
+ title=f"matlab-proxy-app running on {self.settings['app_port']}",
921
+ matlab_urls=_get_server_urls(
922
+ self.settings["mwi_server_url"], mwi_auth_token_str
923
+ ),
514
924
  )
925
+ logger.info(f"MATLAB Root: {self.settings['matlab_path']}")
515
926
 
516
927
  def clean_up_mwi_server_session(self):
517
928
  # Clean up mwi_server_session_files
518
929
  try:
519
930
  for session_file in self.mwi_server_session_files.items():
520
931
  if session_file[1] is not None:
521
- logger.info(f"Deleting:{session_file[1]}")
932
+ logger.debug(f"Deleting:{session_file[1]}")
522
933
  session_file[1].unlink()
523
934
  except FileNotFoundError:
524
935
  # Files may not exist if cleanup is called before they are created
525
936
  pass
526
937
 
527
938
  async def __setup_env_for_matlab(self) -> dict:
528
- """Configure the environment variables required for launching MATLAB by matlab-proxy.
939
+ """Configure the environment variables required for starting MATLAB by matlab-proxy.
529
940
 
530
941
  Returns:
531
942
  [dict]: Containing keys as the Env variable names and values are its corresponding values.
532
943
  """
533
944
  matlab_env = os.environ.copy()
945
+
534
946
  # Env setup related to licensing
947
+ # No additional env setup required if licensing type is set to existing_license
535
948
  if self.licensing["type"] == "mhlm":
536
949
  try:
537
950
  # Request an access token
@@ -543,12 +956,6 @@ class AppState:
543
956
  matlab_env["MLM_WEB_LICENSE"] = "true"
544
957
  matlab_env["MLM_WEB_USER_CRED"] = access_token_data["token"]
545
958
  matlab_env["MLM_WEB_ID"] = self.licensing["entitlement_id"]
546
- matlab_env["MW_LOGIN_EMAIL_ADDRESS"] = self.licensing["email_addr"]
547
- matlab_env["MW_LOGIN_FIRST_NAME"] = self.licensing["first_name"]
548
- matlab_env["MW_LOGIN_LAST_NAME"] = self.licensing["last_name"]
549
- matlab_env["MW_LOGIN_DISPLAY_NAME"] = self.licensing["display_name"]
550
- matlab_env["MW_LOGIN_USER_ID"] = self.licensing["user_id"]
551
- matlab_env["MW_LOGIN_PROFILE_ID"] = self.licensing["profile_id"]
552
959
 
553
960
  matlab_env["MHLM_CONTEXT"] = (
554
961
  "MATLAB_JAVASCRIPT_DESKTOP"
@@ -562,10 +969,11 @@ class AppState:
562
969
  matlab_env["MLM_LICENSE_FILE"] = self.licensing["conn_str"]
563
970
 
564
971
  # Env setup related to MATLAB
565
- matlab_env["MW_CRASH_MODE"] = "native"
566
- matlab_env["MATLAB_WORKER_CONFIG_ENABLE_LOCAL_PARCLUSTER"] = "true"
567
- matlab_env["PCT_ENABLED"] = "true"
568
- matlab_env["HTTP_MATLAB_CLIENT_GATEWAY_PUBLIC_PORT"] = "1"
972
+ ## Update the values only if it does not already exist in the environment
973
+ matlab_env["MW_CRASH_MODE"] = matlab_env.get("MW_CRASH_MODE", "native")
974
+ matlab_env["MATLAB_WORKER_CONFIG_ENABLE_LOCAL_PARCLUSTER"] = matlab_env.get(
975
+ "MATLAB_WORKER_CONFIG_ENABLE_LOCAL_PARCLUSTER", "true"
976
+ )
569
977
  matlab_env["MW_DOCROOT"] = os.path.join("ui", "webgui", "src")
570
978
  matlab_env["MWAPIKEY"] = self.settings["mwapikey"]
571
979
 
@@ -577,25 +985,59 @@ class AppState:
577
985
  # DDUX info for MATLAB
578
986
  matlab_env["MW_CONTEXT_TAGS"] = self.settings.get("mw_context_tags")
579
987
 
988
+ # Update DISPLAY env variable for MATLAB only if it was supplied by Xvfb.
580
989
  if system.is_linux():
581
- # Adding DISPLAY key which is only available after starting Xvfb successfully.
582
- matlab_env["DISPLAY"] = self.settings["matlab_display"]
583
-
584
- # MW_CONNECTOR_SECURE_PORT and MATLAB_LOG_DIR keys to matlab_env as they are available after
585
- # reserving port and preparing lockfiles for MATLAB
586
- matlab_env["MW_CONNECTOR_SECURE_PORT"] = str(self.matlab_port)
990
+ if self.settings.get("matlab_display", None):
991
+ matlab_env["DISPLAY"] = self.settings["matlab_display"]
992
+ logger.debug(
993
+ f"Using the display number supplied by Xvfb process'{matlab_env['DISPLAY']}' for starting MATLAB"
994
+ )
995
+ else:
996
+ if "DISPLAY" in matlab_env:
997
+ logger.debug(
998
+ f"Using the existing DISPLAY environment variable with value:{matlab_env['DISPLAY']} for starting MATLAB"
999
+ )
1000
+ else:
1001
+ logger.debug(
1002
+ "No DISPLAY environment variable found. Starting MATLAB without it."
1003
+ )
587
1004
 
588
1005
  # The matlab ready file is written into this location(self.mwi_logs_dir) by MATLAB
589
1006
  # The mwi_logs_dir is where MATLAB will write any subsequent logs
590
1007
  matlab_env["MATLAB_LOG_DIR"] = str(self.mwi_logs_dir)
591
1008
 
1009
+ # Set MW_CONNECTOR_CONTEXT_ROOT
1010
+ matlab_env["MW_CONNECTOR_CONTEXT_ROOT"] = self.settings.get("base_url", "/")
1011
+ logger.debug(
1012
+ f"MW_CONNECTOR_CONTEXT_ROOT is set to: {matlab_env['MW_CONNECTOR_CONTEXT_ROOT']}"
1013
+ )
1014
+
592
1015
  # Env setup related to logging
593
1016
  # Very verbose logging in debug mode
594
1017
  if logger.isEnabledFor(logging.getLevelName("DEBUG")):
595
- matlab_env["MW_DIAGNOSTIC_DEST"] = "stdout"
596
- matlab_env[
597
- "MW_DIAGNOSTIC_SPEC"
598
- ] = "connector::http::server=all;connector::lifecycle=all"
1018
+ mwi_log_file = self.settings.get("mwi_log_file", None)
1019
+ # If a log file is supplied to write matlab-proxy server logs,
1020
+ # use it to write MATLAB logs too.
1021
+ if mwi_log_file:
1022
+ # Append MATLAB logs to matlab-proxy logs
1023
+ matlab_env["MW_DIAGNOSTIC_DEST"] = f"file,append={mwi_log_file}"
1024
+
1025
+ elif system.is_posix():
1026
+ matlab_env["MW_DIAGNOSTIC_DEST"] = "stdout"
1027
+
1028
+ else:
1029
+ # On windows stdout is not supported yet.
1030
+ # So, use the default log file for MATLAB logs
1031
+ matlab_logs_file = self.mwi_logs_dir / MATLAB_LOGS_FILE_NAME
1032
+ # Write MATLAB logs
1033
+ matlab_env["MW_DIAGNOSTIC_DEST"] = f"file={matlab_logs_file}"
1034
+
1035
+ logger.info(
1036
+ f"Writing MATLAB process logs to: {matlab_env['MW_DIAGNOSTIC_DEST']}"
1037
+ )
1038
+ matlab_env["MW_DIAGNOSTIC_SPEC"] = (
1039
+ "connector::http::server=all;connector::lifecycle=all"
1040
+ )
599
1041
 
600
1042
  # TODO Introduce a warmup flag to enable this?
601
1043
  # matlab_env["CONNECTOR_CONFIGURABLE_WARMUP_TASKS"] = "warmup_hgweb"
@@ -603,6 +1045,45 @@ class AppState:
603
1045
 
604
1046
  return matlab_env
605
1047
 
1048
+ def __filter_env_variables(env_vars: dict, prefix: str) -> dict:
1049
+ """Removes the keys that starts with the prefix supplied to this function
1050
+
1051
+ Args:
1052
+ env_vars (dict): dict to be filtered
1053
+ prefix (str): starting characters of the keys to be removed
1054
+
1055
+ Returns:
1056
+ dict: dict with filtered keys
1057
+ """
1058
+ return {
1059
+ key: value for key, value in env_vars.items() if not key.startswith(prefix)
1060
+ }
1061
+
1062
+ async def __start_window_manager(self, display=None):
1063
+ if display is None:
1064
+ logger.info("Not starting fluxbox as display is not provided")
1065
+ return None
1066
+
1067
+ wm_env = os.environ.copy()
1068
+ wm_env["DISPLAY"] = display
1069
+ wm_cmd = ["fluxbox", "-screen", "0", "-log", "/dev/null"]
1070
+
1071
+ try:
1072
+ logger.info(f"Starting window manager with DISPLAY={wm_env['DISPLAY']}")
1073
+ return await asyncio.create_subprocess_exec(
1074
+ *wm_cmd, close_fds=False, env=wm_env, stderr=asyncio.subprocess.PIPE
1075
+ )
1076
+
1077
+ except Exception as err:
1078
+ self.error = WindowManagerError(
1079
+ "Unable to start the Fluxbox Window Manager due to the following error: "
1080
+ + err
1081
+ )
1082
+ # Log the error on the console.
1083
+ log_error(logger, self.error)
1084
+
1085
+ return None
1086
+
606
1087
  async def __start_xvfb_process(self):
607
1088
  """Private method to start the xvfb process. Will set appropriate
608
1089
  errors to self.error and return None when any exceptions are raised.
@@ -614,21 +1095,26 @@ class AppState:
614
1095
  # Start Xvfb process and update display number in settings
615
1096
  create_xvfb_cmd = self.settings["create_xvfb_cmd"]
616
1097
  xvfb_cmd, dpipe = create_xvfb_cmd()
1098
+ filtered_env_variables = AppState.__filter_env_variables(
1099
+ os.environ.copy(), "MWI_"
1100
+ )
617
1101
 
618
1102
  try:
619
- xvfb, display_port = await mw.create_xvfb_process(xvfb_cmd, dpipe)
1103
+ xvfb, display_port = await mw.create_xvfb_process(
1104
+ xvfb_cmd, dpipe, filtered_env_variables
1105
+ )
620
1106
  self.settings["matlab_display"] = ":" + str(display_port)
621
1107
 
622
- logger.debug(f"Started Xvfb with PID={xvfb.pid} on DISPLAY={display_port}")
1108
+ logger.info(f"Started Xvfb with PID={xvfb.pid} on DISPLAY={display_port}")
623
1109
 
624
1110
  return xvfb
625
1111
 
626
- # If something went wrong ie. exception is raised in launching Xvfb process, capture error for logging
1112
+ # If something went wrong ie. exception is raised in starting Xvfb process, capture error for logging
627
1113
  # and for showing the error on the frontend.
628
1114
 
629
1115
  # FileNotFoundError: is thrown if Xvfb is not found on System Path.
630
- # XvfbError: is thrown if something went wrong when launching Xvfb process.
631
- except (FileNotFoundError, XvfbError) as err:
1116
+ # XvfbError: is thrown if something went wrong when starting Xvfb process.
1117
+ except (FileNotFoundError, XvfbError):
632
1118
  self.error = XvfbError(
633
1119
  """Unable to start the Xvfb process. Ensure Xvfb is installed and is available on the System Path. See https://github.com/mathworks/matlab-proxy#requirements for information on Xvfb"""
634
1120
  )
@@ -650,12 +1136,18 @@ class AppState:
650
1136
  Returns:
651
1137
  (asyncio.subprocess.Process | psutil.Process): If process creation is successful, else return None.
652
1138
  """
1139
+ # If there's no matlab_cmd available, it means that MATLAB is not available on system PATH.
1140
+ if not self.settings["matlab_cmd"]:
1141
+ raise MatlabInstallError(
1142
+ "Unable to find MATLAB on the system PATH. Add MATLAB to the system PATH, and restart matlab-proxy."
1143
+ )
1144
+
653
1145
  if system.is_posix():
654
1146
  import pty
655
1147
 
656
1148
  _, slave = pty.openpty()
657
1149
 
658
- # In posix systems 'matlab' variable is of type asyncio.subprocess.Process()
1150
+ # In POSIX systems, the 'matlab' variable is of type asyncio.subprocess.Process()
659
1151
  matlab = await asyncio.create_subprocess_exec(
660
1152
  *self.settings["matlab_cmd"],
661
1153
  env=matlab_env,
@@ -667,13 +1159,17 @@ class AppState:
667
1159
 
668
1160
  else:
669
1161
  try:
670
- # In Windows systems 'matlab' variable is of type psutil.Process()
1162
+ # In WINDOWS systems, the 'matlab' variable is of type psutil.Process()
671
1163
  matlab = await windows.start_matlab(
672
1164
  self.settings["matlab_cmd"], matlab_env
673
1165
  )
674
1166
 
675
1167
  return matlab
676
1168
 
1169
+ except UIVisibleFatalError as e:
1170
+ self.error = e
1171
+ log_error(logger, e)
1172
+
677
1173
  except Exception as err:
678
1174
  self.error = err
679
1175
  log_error(logger, err)
@@ -681,40 +1177,157 @@ class AppState:
681
1177
  # If something went wrong in starting matlab, return None
682
1178
  return None
683
1179
 
684
- async def start_matlab(self, restart_matlab=False):
685
- """Start MATLAB.
1180
+ async def __force_stop_matlab(self, error, task):
1181
+ """A private method to update self.error and force stop matlab"""
1182
+ self.error = MatlabError(error)
1183
+ logger.error(f"{task}: {error}")
686
1184
 
687
- Args:
688
- restart_matlab (bool, optional): Whether to restart MATLAB. Defaults to False.
1185
+ # If force_quit is not set to True, stop_matlab() would try to
1186
+ # send a HTTP request to the Embedded Connector (which is already "down")
1187
+ await self.stop_matlab(force_quit=True)
689
1188
 
690
- Raises:
691
- Exception: When MATLAB is already running and restart is False.
692
- Exception: When MATLAB is not licensed.
1189
+ async def __track_embedded_connector_state(self):
1190
+ """track_embedded_connector_state is an asyncio task to track the status of MATLAB Embedded Connector.
1191
+ This task will start and stop with the MATLAB process.
693
1192
  """
1193
+ this_task = "track_embedded_connector_state:"
1194
+ logger.debug(f"{this_task}: Starting task...")
694
1195
 
695
- # FIXME
696
- if await self.get_matlab_state() != "down" and restart_matlab is False:
697
- raise Exception("MATLAB already running/starting!")
1196
+ while True:
1197
+ if self.embedded_connector_state == "up":
1198
+ logger.debug(
1199
+ f"{this_task}: MATLAB Embedded Connector is up, not checking for any errors in MATLABs stderr pipe. Sleeping for 10 seconds..."
1200
+ )
1201
+ # Embedded connector is up, sleep for 10 seconds and recheck again
1202
+ await asyncio.sleep(10)
1203
+ continue
698
1204
 
699
- # FIXME
700
- if not self.is_licensed():
701
- raise Exception("MATLAB is not licensed!")
1205
+ # Embedded connector is down, so check for how long it has been down and error out if necessary
1206
+ # embedded_connector_start_time variable is updated by get_matlab_state().
1207
+ else:
1208
+ # If its not yet set, sleep for 1 second and recheck again
1209
+ if not self.embedded_connector_start_time:
1210
+ await asyncio.sleep(1)
1211
+ continue
702
1212
 
703
- if not self.is_matlab_present():
704
- self.error = MatlabInstallError("'matlab' executable not found in PATH")
705
- logger.error("'matlab' executable not found in PATH")
706
- self.logs["matlab"].clear()
707
- return
1213
+ else:
1214
+ time_diff = time.time() - self.embedded_connector_start_time
1215
+ if time_diff > self.PROCESS_TIMEOUT:
1216
+ # Since max allowed startup time has elapsed, it means that MATLAB is stuck and is unable to start.
1217
+ # Set the error and stop matlab.
1218
+ user_visible_error = "Unable to start MATLAB.\nTry again by clicking Start MATLAB."
1219
+
1220
+ if system.is_windows():
1221
+ # In WINDOWS systems, errors are raised as UI windows and cannot be captured programmatically.
1222
+ # So, raise a generic error wherever appropriate
1223
+ generic_error = f"MATLAB did not start in {int(self.PROCESS_TIMEOUT)} seconds. Use Windows Remote Desktop to check for any errors."
1224
+ logger.error(f":{this_task}: {generic_error}")
1225
+
1226
+ # Stopping the MATLAB process would remove the UI window displaying the error too.
1227
+ # Do not stop the MATLAB or break from the loop (as the error is still unknown)
1228
+ self.error = MatlabError(generic_error)
1229
+ await asyncio.sleep(5)
1230
+ continue
708
1231
 
1232
+ else:
1233
+ # If there are no logs after the max startup time has elapsed, it means that MATLAB is stuck and is unable to start.
1234
+ # Set the error and stop matlab.
1235
+ logger.error(
1236
+ f":{this_task}: MATLAB did not start in {int(self.PROCESS_TIMEOUT)} seconds!"
1237
+ )
1238
+ # MATLAB can be stopped on posix systems because the stderr pipe of the MATLAB process is
1239
+ # read (by __matlab_stderr_reader_posix() task) and is logged by matlab-proxy appropriately.
1240
+ await self.__force_stop_matlab(
1241
+ user_visible_error, this_task
1242
+ )
1243
+ # Breaking out of the loop to end this task as matlab-proxy was unable to start MATLAB successfully
1244
+ # even after waiting for self.PROCESS_TIMEOUT
1245
+ break
1246
+
1247
+ else:
1248
+ logger.debug(
1249
+ f"{this_task}: MATLAB has been in a 'starting' state for {int(time_diff)} seconds. Sleeping for 1 second..."
1250
+ )
1251
+ await asyncio.sleep(1)
1252
+
1253
+ async def __matlab_stderr_reader_posix(self):
1254
+ """matlab_stderr_reader_posix is an asyncio task which reads the stderr pipe of the MATLAB process, parses it
1255
+ and updates state variables accordingly.
1256
+ """
1257
+ if system.is_posix():
1258
+ matlab = self.processes["matlab"]
1259
+ logger.debug("matlab_stderr_reader_posix() task: Starting task...")
1260
+
1261
+ while not matlab.stderr.at_eof():
1262
+ logger.debug(
1263
+ "matlab_stderr_reader_posix() task: Waiting to read data from stderr pipe..."
1264
+ )
1265
+ line = await matlab.stderr.readline()
1266
+ if line is None:
1267
+ logger.debug(
1268
+ "matlab_stderr_reader_posix() task: Received data from stderr pipe appending to logs..."
1269
+ )
1270
+ break
1271
+ self.logs["matlab"].append(line)
1272
+ await self.handle_matlab_output()
1273
+
1274
+ async def __update_matlab_port(self, delay: int):
1275
+ """Task to populate matlab_port from the matlab ready file. Times out if max_duration is breached
1276
+
1277
+ Args:
1278
+ delay (int): time delay in seconds before retrying the file read operation
1279
+ """
1280
+ logger.debug(
1281
+ f"updating matlab_port information from {self.matlab_session_files['matlab_ready_file']}"
1282
+ )
1283
+ try:
1284
+ await asyncio.wait_for(
1285
+ self.__read_matlab_ready_file(delay),
1286
+ self.PROCESS_TIMEOUT,
1287
+ )
1288
+ except asyncio.TimeoutError:
1289
+ logger.debug(
1290
+ "Timeout error received while updating matlab port, stopping matlab!"
1291
+ )
1292
+ await self.stop_matlab(force_quit=True)
1293
+ self.error = MatlabError(
1294
+ "MATLAB startup has timed out. Click Start MATLAB to try again."
1295
+ )
1296
+
1297
+ async def __read_matlab_ready_file(self, delay):
1298
+ # reads with delays from the file where connector has written its port information
1299
+ while not self.matlab_session_files["matlab_ready_file"].exists():
1300
+ await asyncio.sleep(delay)
1301
+
1302
+ with open(self.matlab_session_files["matlab_ready_file"]) as f:
1303
+ self.matlab_port = int(f.read())
1304
+ logger.debug(
1305
+ f"MATLAB Ready file successfully read, matlab_port set to: {self.matlab_port}"
1306
+ )
1307
+
1308
+ async def start_matlab(self, restart_matlab=False):
1309
+ """Start MATLAB.
1310
+
1311
+ Args:
1312
+ restart_matlab (bool, optional): Whether to restart MATLAB. Defaults to False.
1313
+ """
709
1314
  # Ensure that previous processes are stopped
710
1315
  await self.stop_matlab()
711
1316
 
1317
+ # Acquire lock before setting MATLAB state to 'starting'.
1318
+
1319
+ # The lock is held for a substantial part of this function's execution to prevent asynchronous updates
1320
+ # to MATLAB state by other functions/tasks until the lock is released, ensuring consistency. It's released early only in case of exceptions.
1321
+ await self.matlab_state_updater_lock.acquire()
1322
+ self.set_matlab_state("starting")
1323
+ logger.info("Starting MATLAB...")
1324
+
712
1325
  # Clear MATLAB errors and logging
713
1326
  self.error = None
714
1327
  self.logs["matlab"].clear()
715
1328
 
716
- # Start Xvfb process if in a posix system
717
- if system.is_linux():
1329
+ # Start Xvfb process on linux if possible
1330
+ if system.is_linux() and self.settings["is_xvfb_available"]:
718
1331
  xvfb = await self.__start_xvfb_process()
719
1332
 
720
1333
  # xvfb variable would be None if creation of the process failed.
@@ -724,31 +1337,49 @@ class AppState:
724
1337
 
725
1338
  self.processes["xvfb"] = xvfb
726
1339
 
1340
+ # Start Window Manager on linux if possible
1341
+ if system.is_linux() and self.settings["is_windowmanager_available"]:
1342
+ display = self.settings.get("matlab_display", None)
1343
+ await self.__start_window_manager(display)
1344
+
727
1345
  try:
728
- # Finds and reserves a free port, then prepare lock files for the MATLAB process.
729
- self.prepare_lock_files_for_MATLAB_launch()
1346
+
1347
+ # Prepare ready file for the MATLAB process.
1348
+ self.create_logs_dir_for_MATLAB()
730
1349
 
731
1350
  # Configure the environment MATLAB needs to start
732
1351
  matlab_env = await self.__setup_env_for_matlab()
733
1352
 
734
1353
  logger.debug(
735
- "Prepared lock files and configured the environment for MATLAB startup"
1354
+ "Prepared ready file and configured the environment for MATLAB startup"
736
1355
  )
737
1356
 
738
- # If there's something wrong with setting up lock files or env setup for starting matlab, capture the error for logging
1357
+ # If there's something wrong with setting up files or env setup for starting matlab, capture the error for logging
739
1358
  # and to pass to the front-end. Halt MATLAB process startup by returning early
740
1359
  except Exception as err:
1360
+ # Release lock if an exception occurs as we are returning early and since it will be required by stop_matlab
1361
+ await self.matlab_state_updater_lock.release()
741
1362
  self.error = err
742
1363
  log_error(logger, err)
743
1364
  # stop_matlab() does the teardown work by removing any residual files and processes created till now
744
- # which is Xvfb process creation and preparing lock files for the MATLAB process.
1365
+ # which is Xvfb process creation and ready file for the MATLAB process.
745
1366
  await self.stop_matlab()
746
1367
  return
747
1368
 
748
1369
  # Start MATLAB Process
749
- logger.debug(f"Starting MATLAB on port {self.matlab_port}")
1370
+ logger.debug("Starting MATLAB")
750
1371
 
751
- matlab = await self.__start_matlab_process(matlab_env)
1372
+ try:
1373
+ matlab = await self.__start_matlab_process(matlab_env)
1374
+
1375
+ # If there's an error with starting MATLAB, set the error to the state and matlab to None
1376
+ except MatlabInstallError as err:
1377
+ log_error(logger, err)
1378
+ self.error = err
1379
+ matlab = None
1380
+
1381
+ # Release the lock after MATLAB process has started.
1382
+ await self.matlab_state_updater_lock.release()
752
1383
 
753
1384
  # matlab variable would be None if creation of the process failed.
754
1385
  if matlab is None:
@@ -760,138 +1391,20 @@ class AppState:
760
1391
  logger.debug(f"Started MATLAB (PID={matlab.pid})")
761
1392
  self.processes["matlab"] = matlab
762
1393
 
763
- async def matlab_stderr_reader():
764
- matlab = self.processes["matlab"]
765
- logger.info("matlab_stderr_reader() task: Starting task...")
766
-
767
- if system.is_posix():
768
- while not matlab.stderr.at_eof():
769
- logger.debug(
770
- "matlab_stderr_reader() task: Waiting to read data from stderr pipe..."
771
- )
772
- line = await matlab.stderr.readline()
773
- if line is None:
774
- logger.debug(
775
- "matlab_stderr_reader() task: Received data from stderr pipe appending to logs..."
776
- )
777
- break
778
- self.logs["matlab"].append(line)
779
- await self.handle_matlab_output()
780
-
781
- else:
782
- # starting state time stamp.
783
- # This is used to keep track of when the MATLAB process' state
784
- # has changed to 'starting'.
785
- # If there is some problem with lauching the Embedded Connector,
786
- # MATLAB will continue to be in a 'starting' state indefinitely.
787
- # Used only on Windows system.
788
- self.starting_state_timestamp = None
789
-
790
- # The maximum amount of time in seconds the Embedded Connector can take
791
- # for lauching, before the matlab-proxy server concludes that something is wrong.
792
- self.embedded_connector_max_starting_duration = 120
793
-
794
- # In Windows systems, errors are raised as UI windows and cannot be captured programmatically.
795
- # So, check for how long the Embedded Connector is not up and then raise a generic error.
796
- while True:
797
- # If the Embedded connector is up, everything is ok, sleep for 10 seconds.
798
- if await self.get_matlab_state() == "up":
799
- logger.debug(
800
- "matlab_stderr_reader() task: MATLAB is up, not checking for any errors. Sleeping for 10 seconds..."
801
- )
802
- # Setting starting_state_timestamp to None
803
- # If something goes wrong or MATLAB process is restarted
804
- # self.get_matlab_state() will update the variable to the appropriate timestamp.
805
- self.starting_state_timestamp = None
806
- await asyncio.sleep(10)
807
- continue
808
-
809
- # If starting_state_timestamp is not yet set, it means MATLAB process has
810
- # not yet started. So, wait for MATLAB to start and for starting_state_timestamp to be set.
811
- if not self.starting_state_timestamp:
812
- await asyncio.sleep(1)
813
- continue
814
-
815
- time_diff = time.time() - self.starting_state_timestamp
816
- if time_diff > self.embedded_connector_max_starting_duration:
817
- # If execution reaches here, it means that the MATLAB has been up but the Embedded Connector is not
818
- # responding for more than embedded_connector_max_starting_duration seconds. So, create/raise a generic error
819
- logger.error(
820
- f"matlab_stderr_reader() task: MATLAB has been in a 'starting' state for more than {self.embedded_connector_max_starting_duration}!"
821
- )
822
- self.error = MatlabError(
823
- f"MATLAB has been in a starting state for more than {self.embedded_connector_max_starting_duration} seconds. Use Windows Remote Desktop to check for any errors"
824
- )
825
-
826
- else:
827
- logger.debug(
828
- f"matlab_stderr_reader() task: MATLAB has been in a 'starting' state for {time_diff} seconds. Sleeping for 1 second..."
829
- )
830
- await asyncio.sleep(1)
831
-
832
1394
  loop = util.get_event_loop()
833
-
834
- self.tasks["matlab_stderr_reader"] = loop.create_task(matlab_stderr_reader())
835
-
836
- """
837
- async def __send_terminate_integration_request(self):
838
- Private method to programmatically shutdown the matlab-proxy server.
839
- Sends a HTTP request to the server to shut itself down gracefully.
840
-
841
- # Clean up session files which determine various states of the server &/ MATLAB.
842
- # Do this first as stopping MATLAB/Xvfb take longer and may fail
843
- try:
844
- for session_file in self.matlab_session_files.items():
845
- if session_file[1] is not None:
846
- logger.info(f"Deleting:{session_file[1]}")
847
- session_file[1].unlink()
848
- except FileNotFoundError:
849
- # Files won't exist when stop_matlab is called for the first time.
850
- pass
851
-
852
- # Cancel the asyncio task which reads MATLAB process' stderr
853
- if "matlab_stderr_reader" in self.tasks:
854
- try:
855
- self.tasks["matlab_stderr_reader"].cancel()
856
- except asyncio.CancelledError:
857
- pass
858
- If the request fails, the server process exits with error code 1.
859
-
860
- url = self.settings["mwi_server_url"] + "/terminate_integration"
861
-
862
- try:
863
- async with aiohttp.ClientSession() as client_session:
864
- async with client_session.delete(url) as res:
865
- pass
866
-
867
- matlab = self.processes["matlab"]
868
- if matlab is not None and matlab.returncode is None:
869
- try:
870
- logger.info(
871
- f"Calling terminate on MATLAB process with PID: {matlab.pid}!"
872
- )
873
- matlab.terminate()
874
- await matlab.wait()
875
- except:
876
- logger.info(
877
- f"Exception occured during termination of MATLAB process with PID: {matlab.pid}!"
878
- )
879
- pass
880
-
881
- xvfb = self.processes["xvfb"]
882
- logger.debug(f"Attempting XVFB Termination Xvfb)")
883
- if xvfb is not None and xvfb.returncode is None:
884
- logger.info(f"Terminating Xvfb (PID={xvfb.pid})")
885
- xvfb.terminate()
886
- await xvfb.wait()
887
-
888
- except aiohttp.client_exceptions.ServerDisconnectedError:
889
- logger.error("Server has already disconnected...")
890
-
891
- # If even the terminate integration request fails, exit with error code 1.
892
- except Exception as err:
893
- logger.error("Failed to send terminate integration request:\n", err)
894
- sys.exit(1)"""
1395
+ # Start all tasks relevant to MATLAB process
1396
+ self.matlab_tasks["matlab_stderr_reader_posix"] = loop.create_task(
1397
+ self.__matlab_stderr_reader_posix()
1398
+ )
1399
+ self.matlab_tasks["track_embedded_connector_state"] = loop.create_task(
1400
+ self.__track_embedded_connector_state()
1401
+ )
1402
+ self.matlab_tasks["update_matlab_port"] = loop.create_task(
1403
+ self.__update_matlab_port(self.MATLAB_PORT_CHECK_DELAY_IN_SECONDS)
1404
+ )
1405
+ self.matlab_tasks["update_matlab_state"] = loop.create_task(
1406
+ self.__update_matlab_state()
1407
+ )
895
1408
 
896
1409
  async def __send_stop_request_to_matlab(self):
897
1410
  """Private method to send a HTTP request to MATLAB to shutdown gracefully
@@ -902,12 +1415,16 @@ class AppState:
902
1415
 
903
1416
  try:
904
1417
  data = mwi.embedded_connector.helpers.get_data_to_eval_mcode("exit")
1418
+ headers = self._get_token_auth_headers()
905
1419
  url = mwi.embedded_connector.helpers.get_mvm_endpoint(
906
1420
  self.settings["mwi_server_url"]
907
1421
  )
908
1422
 
909
1423
  resp_json = await mwi.embedded_connector.send_request(
910
- url=url, method="POST", data=data, headers=None
1424
+ url=url,
1425
+ method="POST",
1426
+ data=data,
1427
+ headers=headers,
911
1428
  )
912
1429
 
913
1430
  if resp_json["messages"]["EvalResponse"][0]["isError"]:
@@ -920,27 +1437,47 @@ class AppState:
920
1437
 
921
1438
  async def stop_matlab(self, force_quit=False):
922
1439
  """Terminate MATLAB."""
1440
+
1441
+ matlab_state = self.get_matlab_state()
1442
+
1443
+ # Acquire lock before setting MATLAB state to 'stopping'.
1444
+
1445
+ # The lock is held for a substantial part of this function's execution to prevent asynchronous updates
1446
+ # to MATLAB state by other functions/tasks until the lock is released, ensuring consistency. It's released early only in case of exceptions.
1447
+ await self.matlab_state_updater_lock.acquire()
1448
+
1449
+ self.set_matlab_state("stopping")
923
1450
  # Clean up session files which determine various states of the server &/ MATLAB.
924
1451
  # Do this first as stopping MATLAB/Xvfb takes longer and may fail
925
- try:
926
- for _, session_file in self.matlab_session_files.items():
927
- if session_file is not None:
928
- logger.info(f"Deleting:{session_file}")
929
- session_file.unlink()
930
- except FileNotFoundError:
931
- # Files won't exist when stop_matlab is called for the first time.
932
- pass
1452
+
1453
+ # Files won't exist when stop_matlab is called for the first time.
1454
+ for (
1455
+ session_file_name,
1456
+ session_file_path,
1457
+ ) in self.matlab_session_files.items():
1458
+ if session_file_path is not None:
1459
+ self.matlab_session_files[session_file_name] = None
1460
+ with contextlib.suppress(FileNotFoundError):
1461
+ logger.debug(f"Deleting:{session_file_path}")
1462
+ session_file_path.unlink()
933
1463
 
934
1464
  # In posix systems, variable matlab is an instance of asyncio.subprocess.Process()
935
- # In windows systems, variable matlab is an isntance of psutil.Process()
1465
+ # In windows systems, variable matlab is an instance of psutil.Process()
936
1466
  matlab = self.processes["matlab"]
937
1467
 
938
1468
  waiters = []
939
1469
  if matlab is not None:
940
- # Sending a request to embedded connector works sporadically on posix systems.
941
- # So, terminating the process when force_quit is set to True.
942
1470
  if system.is_posix() and matlab.returncode is None:
943
- if force_quit:
1471
+ # Close the stderr stream to prevent indefinite hanging on it due to a child
1472
+ # process inheriting it, fixes https://github.com/mathworks/matlab-proxy/issues/44
1473
+ self._close_matlab_stderr_stream(matlab)
1474
+
1475
+ # Sending an exit request to the embedded connector takes time.
1476
+ # When MATLAB is in a "starting" state (implies the Embedded connector is not up)
1477
+ # OR
1478
+ # When force_quit is set to True
1479
+ # directly terminate the MATLAB process instead.
1480
+ if matlab_state == "starting" or force_quit:
944
1481
  logger.debug("Forcing the MATLAB process to terminate...")
945
1482
  matlab.terminate()
946
1483
  waiters.append(matlab.wait())
@@ -952,6 +1489,7 @@ class AppState:
952
1489
 
953
1490
  # Wait for matlab to shutdown gracefully
954
1491
  await matlab.wait()
1492
+
955
1493
  assert (
956
1494
  matlab.returncode == 0
957
1495
  ), "Failed to gracefully shutdown MATLAB via the embedded connector"
@@ -966,45 +1504,53 @@ class AppState:
966
1504
  try:
967
1505
  matlab.terminate()
968
1506
  await matlab.wait()
969
- except:
970
- pass
971
-
1507
+ except Exception as ex:
1508
+ logger.debug(
1509
+ "Received an exception while terminating matlab: %s", ex
1510
+ )
972
1511
  else:
973
- if not system.is_posix() and matlab.is_running():
974
- # If in a windows system, send request to embedded connector
975
- # to stop matlab.
976
- logger.debug("Sending HTTP request to stop the MATLAB process...")
977
-
978
- try:
979
- # Send HTTP request
980
- await self.__send_stop_request_to_matlab()
981
-
982
- # Wait for matlab to shutdown gracefully
1512
+ # In a windows system
1513
+ if system.is_windows() and matlab.is_running():
1514
+ if matlab_state == "starting" or force_quit:
1515
+ matlab.terminate()
983
1516
  matlab.wait()
984
- assert (
985
- not matlab.is_running()
986
- ), "Failed to gracefully shutdown MATLAB via the embedded connector"
987
1517
 
988
- logger.debug("Stopped the MATLAB process gracefully")
989
-
990
- except Exception as err:
991
- log_error(logger, err)
992
- logger.info(
993
- "Failed to stop MATLAB gracefully. Attempting to terminate the process."
1518
+ else:
1519
+ # send request to embedded connector to stop matlab.
1520
+ logger.debug(
1521
+ "Sending HTTP request to stop the MATLAB process..."
994
1522
  )
1523
+
995
1524
  try:
996
- matlab.terminate()
1525
+ # Send HTTP request
1526
+ await self.__send_stop_request_to_matlab()
1527
+
1528
+ # Wait for matlab to shutdown gracefully
997
1529
  matlab.wait()
998
- except:
999
- pass
1530
+ assert (
1531
+ not matlab.is_running()
1532
+ ), "Failed to gracefully shutdown MATLAB via the embedded connector"
1533
+
1534
+ logger.debug("Stopped the MATLAB process gracefully")
1000
1535
 
1001
- logger.info("Stopped (any running)MATLAB process.")
1536
+ except Exception as err:
1537
+ log_error(logger, err)
1538
+ logger.info(
1539
+ "Failed to stop MATLAB gracefully. Attempting to terminate the process."
1540
+ )
1541
+ try:
1542
+ matlab.terminate()
1543
+ matlab.wait()
1544
+ except:
1545
+ pass
1546
+
1547
+ logger.debug("Stopped (any running) MATLAB process.")
1002
1548
 
1003
1549
  # Terminating Xvfb
1004
1550
  if system.is_posix():
1005
1551
  xvfb = self.processes["xvfb"]
1006
1552
  if xvfb is not None and xvfb.returncode is None:
1007
- logger.info(f"Terminating Xvfb (PID={xvfb.pid})")
1553
+ logger.debug(f"Terminating Xvfb (PID={xvfb.pid})")
1008
1554
  xvfb.terminate()
1009
1555
  waiters.append(xvfb.wait())
1010
1556
 
@@ -1013,25 +1559,41 @@ class AppState:
1013
1559
  for waiter in waiters:
1014
1560
  await waiter
1015
1561
 
1016
- stderr_reader_task = self.tasks.get("matlab_stderr_reader")
1017
- if stderr_reader_task is not None:
1018
- try:
1019
- stderr_reader_task.cancel()
1020
- await stderr_reader_task
1021
- except asyncio.CancelledError:
1022
- pass
1562
+ # Release lock for the __update_matlab_state task to determine MATLAB state.
1563
+ await self.matlab_state_updater_lock.release()
1023
1564
 
1024
- self.tasks.pop("matlab_stderr_reader")
1565
+ # Canceling all MATLAB process related tasks
1566
+ await util.cancel_tasks(self.matlab_tasks)
1025
1567
 
1026
- logger.info("matlab_stderr_reader() task: Stopped successfully.")
1568
+ # After stopping all the tasks, set self.matlab_tasks to empty dict
1569
+ self.matlab_tasks = {}
1027
1570
 
1028
1571
  # Clear logs if MATLAB stopped intentionally
1029
1572
  logger.debug("Clearing logs!")
1030
1573
  self.logs["matlab"].clear()
1031
1574
  logger.debug("Cleared any logs created by the MATLAB process.")
1032
1575
 
1576
+ # Update matlab_port information in the event of intentionally stopping MATLAB
1577
+ self.matlab_port = None
1033
1578
  logger.debug("Completed Shutdown!!!")
1034
1579
 
1580
+ def _close_matlab_stderr_stream(self, matlab):
1581
+ """
1582
+ This method attempts to close the stderr stream associated with the MATLAB process
1583
+ to prevent potential resource leaks. It logs a debug message if the stream is
1584
+ successfully closed.
1585
+
1586
+ Args:
1587
+ matlab: The MATLAB process reference.
1588
+ """
1589
+ stderr_stream = matlab._transport.get_pipe_transport(sys.stderr.fileno())
1590
+ if stderr_stream:
1591
+ logger.debug(
1592
+ "Closing matlab process stderr stream: %s",
1593
+ stderr_stream,
1594
+ )
1595
+ stderr_stream.close()
1596
+
1035
1597
  async def handle_matlab_output(self):
1036
1598
  """Parse MATLAB output from stdout and raise errors if any."""
1037
1599
  matlab = self.processes["matlab"]
@@ -1064,3 +1626,71 @@ class AppState:
1064
1626
  if err is not None:
1065
1627
  self.error = err
1066
1628
  log_error(logger, err)
1629
+
1630
+ def get_session_status(self, is_desktop, client_id, transfer_session):
1631
+ """
1632
+ Determines the session status for a client, potentially generating a new client ID.
1633
+
1634
+ This function is responsible for managing and tracking the session status of a client.
1635
+ It can generate a new client ID if one is not provided and the conditions are met.
1636
+ It also manages the active client status within the session, especially in scenarios
1637
+ involving desktop clients and when concurrency checks are enabled.
1638
+
1639
+ Args:
1640
+ is_desktop (bool): A flag indicating whether the client is a desktop client.
1641
+ client_id (str or None): The client ID. If None, a new client ID may be generated.
1642
+ transfer_session (bool): Indicates whether the session should be transferred to this client.
1643
+
1644
+ Returns:
1645
+ tuple:
1646
+ - A 2-tuple containing the generated client ID (or None if not generated) and
1647
+ a boolean indicating whether the client is considered the active client.
1648
+ - If concurrency checks are not enabled or the client is not a desktop client, it returns None for both
1649
+ the generated client ID and the active client status.
1650
+ """
1651
+ if IS_CONCURRENCY_CHECK_ENABLED and is_desktop:
1652
+ generated_client_id = None
1653
+ if not client_id:
1654
+ generated_client_id = str(uuid.uuid4())
1655
+ client_id = generated_client_id
1656
+
1657
+ if not self.active_client or transfer_session:
1658
+ self.active_client = client_id
1659
+
1660
+ if not self.server_tasks.get("detect_client_status", None):
1661
+ # Create the loop to detect the active status of the client
1662
+ loop = util.get_event_loop()
1663
+ self.server_tasks["detect_client_status"] = loop.create_task(
1664
+ self.detect_active_client_status()
1665
+ )
1666
+
1667
+ if self.active_client == client_id:
1668
+ is_active_client = True
1669
+ self.active_client_request_detected = True
1670
+ else:
1671
+ is_active_client = False
1672
+ return generated_client_id, is_active_client
1673
+ return None, None
1674
+
1675
+ async def detect_active_client_status(self, sleep_time=1, max_inactive_count=10):
1676
+ """Detects whether the client is online or not by continuously checking if the active client is making requests
1677
+
1678
+ Args:
1679
+ sleep_time (int): The time in seconds for which the process waits before checking for the next get_status request from the active client.
1680
+ max_inactive_count (int): The maximum number of times the check for the request from the active_client fails before reseting the active client id.
1681
+ """
1682
+ inactive_count = 0
1683
+ while self.active_client:
1684
+ # Check if the get_status request from the active client is received or not
1685
+ await asyncio.sleep(sleep_time)
1686
+ if self.active_client_request_detected:
1687
+ self.active_client_request_detected = False
1688
+ inactive_count = 0
1689
+ else:
1690
+ inactive_count = inactive_count + 1
1691
+ if inactive_count > max_inactive_count:
1692
+ # If no request is received from the active_client for more than 10 seconds then clear the active client id
1693
+ inactive_count = 0
1694
+ self.active_client = None
1695
+
1696
+ await util.cancel_tasks([self.server_tasks.get("detect_client_status")])