Skip to content

Commit 14924af

Browse files
diningPhilosopher64Prabhakar Kumar
authored andcommitted
Implement dynamic display port selection for xvfb process
1 parent f43eb7d commit 14924af

File tree

8 files changed

+154
-210
lines changed

8 files changed

+154
-210
lines changed

jupyter_matlab_proxy/app_state.py

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,6 @@ def reserve_matlab_port(self):
303303
# try-except.
304304
# s.bind(("", 0))
305305
# self.matlab_port = s.getsockname()[1]
306-
307306
for port in mw.range_matlab_connector_ports():
308307
try:
309308
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -358,22 +357,11 @@ async def start_matlab(self, restart=False):
358357
except FileNotFoundError:
359358
pass
360359

361-
# The presence of xvfb_ready_file indicates if Xvfb is ready to receive
362-
# connections, but this could be leftover from a terminated Xvfb, so ensure it
363-
# is cleaned up before starting Xvfb
364-
display_num = self.settings["matlab_display"].replace(":", "")
365-
xvfb_ready_file = Path(tempfile.gettempdir()) / ".X11-unix" / f"X{display_num}"
366-
try:
367-
xvfb_ready_file.unlink()
368-
except FileNotFoundError:
369-
pass
370-
371360
# Configure the environment MATLAB needs to start
372361
matlab_env = os.environ.copy()
373362
matlab_env["MW_CRASH_MODE"] = "native"
374363
matlab_env["MATLAB_WORKER_CONFIG_ENABLE_LOCAL_PARCLUSTER"] = "true"
375364
matlab_env["PCT_ENABLED"] = "true"
376-
matlab_env["DISPLAY"] = self.settings["matlab_display"]
377365
matlab_env["HTTP_MATLAB_CLIENT_GATEWAY_PUBLIC_PORT"] = "1"
378366
matlab_env["MW_CONNECTOR_SECURE_PORT"] = str(self.matlab_port)
379367
matlab_env["MW_DOCROOT"] = str(
@@ -410,18 +398,21 @@ async def start_matlab(self, restart=False):
410398
# matlab_env["CONNECTOR_CONFIGURABLE_WARMUP_TASKS"] = "warmup_hgweb"
411399
# matlab_env["CONNECTOR_WARMUP"] = "true"
412400

413-
logger.debug(f"Starting Xvfb on display {self.settings['matlab_display']}")
414-
xvfb = await asyncio.create_subprocess_exec(
415-
*self.settings["xvfb_cmd"], env=matlab_env
416-
)
401+
# Start Xvfb process
402+
create_xvfb_cmd = self.settings["create_xvfb_cmd"]
403+
xvfb_cmd, dpipe = create_xvfb_cmd()
404+
405+
xvfb, display_port = await mw.create_xvfb_process(xvfb_cmd, dpipe, matlab_env)
406+
407+
# Update settings and matlab_env dict
408+
self.settings["matlab_display"] = ":" + str(display_port)
417409
self.processes["xvfb"] = xvfb
418-
logger.debug(f"Started Xvfb (PID={xvfb.pid})")
419410

420-
# Wait for Xvfb to be ready
421-
while not xvfb_ready_file.exists():
422-
logger.debug(f"Waiting for XVFB")
423-
await asyncio.sleep(0.1)
411+
matlab_env["DISPLAY"] = self.settings["matlab_display"]
412+
413+
logger.debug(f"Started Xvfb with PID={xvfb.pid} on DISPLAY={display_port}")
424414

415+
# Start MATLAB Process
425416
logger.info(f"Starting MATLAB on port {self.matlab_port}")
426417
master, slave = pty.openpty()
427418
matlab = await asyncio.create_subprocess_exec(

jupyter_matlab_proxy/devel.py

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@
33
# Development specific functions
44
import asyncio, aiohttp
55
from aiohttp import web
6-
import socket
7-
import time
8-
import os
9-
import sys
6+
import socket, time, os, sys
107

118
desktop_html = b"""
129
<h1>Fake MATLAB Web Desktop</h1>
@@ -39,17 +36,6 @@ def wait_for_port(port):
3936
break
4037

4138

42-
def xvfb(args):
43-
"Writes ready file after 1 second then sleeps forever in a loose loop"
44-
time.sleep(1)
45-
print(f"Creating {str(args.ready_file)}")
46-
args.ready_file.parent.mkdir(parents=True, exist_ok=True)
47-
args.ready_file.touch()
48-
49-
while True:
50-
time.sleep(60)
51-
52-
5339
async def web_handler(request):
5440
return web.Response(content_type="text/html", body=desktop_html)
5541

@@ -147,9 +133,6 @@ def matlab(args):
147133

148134
parser = argparse.ArgumentParser()
149135
subparsers = parser.add_subparsers(dest="cmd", required=True)
150-
xvfb_parser = subparsers.add_parser("xvfb")
151-
xvfb_parser.add_argument("--ready-file", type=Path)
152-
xvfb_parser.set_defaults(func=xvfb)
153136
matlab_parser = subparsers.add_parser("matlab")
154137
matlab_parser.add_argument(
155138
"--ready-file",

jupyter_matlab_proxy/settings.py

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from pathlib import Path
55
import tempfile
66
import xml.etree.ElementTree as ET
7-
import uuid
7+
import uuid, socket
88
import shutil
99

1010

@@ -49,14 +49,7 @@ def get_dev_settings():
4949
"--ready-file",
5050
str(matlab_ready_file),
5151
],
52-
"xvfb_cmd": [
53-
"python",
54-
"-u",
55-
str(devel_file),
56-
"xvfb",
57-
"--ready-file",
58-
str(Path(tempfile.gettempdir()) / ".X11-unix" / "X1"),
59-
],
52+
"create_xvfb_cmd": create_xvfb_cmd,
6053
"matlab_ready_file": matlab_ready_file,
6154
"base_url": os.environ.get("BASE_URL", ""),
6255
"app_port": os.environ.get("APP_PORT", 8000),
@@ -99,6 +92,12 @@ def get(dev=False):
9992
matlab_cmd[4:4] = ready_delay
10093
settings["matlab_cmd"] = matlab_cmd
10194

95+
# Randomly picks an available port and updates the value of settings['app_port'] .
96+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
97+
s.bind(("", 0))
98+
settings["app_port"] = s.getsockname()[1]
99+
s.close()
100+
102101
# Set NLM Connection string. Server will start using this connection string for licensing
103102
settings["nlm_conn_str"] = "abc@nlm"
104103

@@ -122,32 +121,49 @@ def get(dev=False):
122121
"-r",
123122
f"try; run('{matlab_startup_file}'); catch; end;",
124123
],
125-
"xvfb_cmd": [
126-
"Xvfb",
127-
":1",
128-
"-screen",
129-
"0",
130-
"1600x1200x24",
131-
"-dpi",
132-
"100",
133-
# "-ac",
134-
"-extension",
135-
"RANDR",
136-
# "+render",
137-
# "-noreset",
138-
],
124+
"create_xvfb_cmd": create_xvfb_cmd,
139125
"matlab_ready_file": Path(tempfile.gettempdir()) / "connector.securePort",
140126
"base_url": os.environ["BASE_URL"],
141127
"app_port": os.environ["APP_PORT"],
142128
"host_interface": os.environ.get("APP_HOST"),
143129
"mwapikey": str(uuid.uuid4()),
144130
"matlab_protocol": "https",
145-
# TODO: Uncomment this ?
146-
# "matlab_display": ":1",
147131
"nlm_conn_str": os.environ.get("MLM_LICENSE_FILE"),
148132
"matlab_config_file": Path.home() / ".matlab" / "proxy_app_config.json",
149133
"ws_env": ws_env,
150134
"mwa_api_endpoint": f"https://login{ws_env_suffix}.mathworks.com/authenticationws/service/v4",
151135
"mhlm_api_endpoint": f"https://licensing{ws_env_suffix}.mathworks.com/mls/service/v1/entitlement/list",
152136
"mwa_login": f"https://login{ws_env_suffix}.mathworks.com",
153137
}
138+
139+
140+
def create_xvfb_cmd():
141+
"""Creates the Xvfb command with a write descriptor.
142+
143+
Returns:
144+
List: Containing 2 lists.
145+
146+
The second List contains a read and a write descriptor.
147+
The first List is the command to launch Xvfb process with the same write descriptor(from the first list) embedded in the command.
148+
"""
149+
dpipe = os.pipe()
150+
os.set_inheritable(dpipe[1], True)
151+
152+
xvfb_cmd = [
153+
"Xvfb",
154+
"-displayfd",
155+
# Write descriptor
156+
str(dpipe[1]),
157+
"-screen",
158+
"0",
159+
"1600x1200x24",
160+
"-dpi",
161+
"100",
162+
# "-ac",
163+
"-extension",
164+
"RANDR",
165+
# "+render",
166+
# "-noreset",
167+
]
168+
169+
return xvfb_cmd, dpipe

jupyter_matlab_proxy/util/mw.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright 2020-2021 The MathWorks, Inc.
22

33
import xml.etree.ElementTree as ET
4-
import aiohttp
4+
import aiohttp, os, asyncio, select, logging
55
from .exceptions import (
66
OnlineLicensingError,
77
EntitlementError,
@@ -11,6 +11,7 @@
1111

1212

1313
LICENSING_URL = "https://github.com/mathworks/jupyter-matlab-proxy/blob/main/MATLAB_Licensing_Info.md"
14+
logger = logging.getLogger("MATLABProxyApp")
1415

1516

1617
async def fetch_entitlements(mhlm_api_endpoint, access_token, matlab_release):
@@ -179,3 +180,63 @@ def parse_other_error(logs):
179180
"MATLAB returned an unexpected error. For more details, see the log below.",
180181
logs=logs,
181182
)
183+
184+
185+
async def create_xvfb_process(xvfb_cmd, pipe, matlab_env={}):
186+
"""Creates the Xvfb process.
187+
188+
The Xvfb process is run with '-displayfd' flag set. This makes Xvfb choose an available
189+
display number and write it into the provided write descriptor. ie: pipe[1]
190+
191+
We read this display number from the read descriptor.
192+
For this, we have 2 things to consider:
193+
1. How many bytes to read from the read descriptor. This is handled by the variable number_of_bytes.
194+
195+
2. For how long to read from the read descriptor. We wait for atmost 10 seconds for Xvfb to write
196+
the display number using the 'select' package.
197+
198+
199+
Args:
200+
xvfb_cmd (List): A list containing the command to run the Xvfb process
201+
pipe (List): A list containing a pair of file descriptor.
202+
matlab_env (Dict): A Dict containing environment variables within which the Xvfb process is created.
203+
204+
Returns:
205+
[List]: Containing the Xvfb process object, and display number on which Xvfb process has started.
206+
"""
207+
208+
# Creates subprocess asynchronously with environment variables defined in matlab_env
209+
# Pipe errors, if any, to the process object instead of stdout.
210+
xvfb = await asyncio.create_subprocess_exec(
211+
*xvfb_cmd, close_fds=False, env=matlab_env, stderr=asyncio.subprocess.PIPE
212+
)
213+
214+
read_descriptor, write_descriptor = pipe
215+
number_of_bytes = 200
216+
217+
logger.debug("Waiting for XVFB process to initialize and provide Display Number")
218+
# Waits upto 10 seconds for the read_descriptor to be ready.
219+
ready_descriptors, _, _ = select.select([read_descriptor], [], [], 10)
220+
221+
# If read_descriptor is in ready_descriptors, read from it.
222+
if read_descriptor in ready_descriptors:
223+
logger.debug("Reading display number from the read descriptor.")
224+
line = os.read(read_descriptor, number_of_bytes).decode("utf-8")
225+
# Xvfb process writes the display number and adds a new line character ('\n') at the end. Removing it with .strip()
226+
display_port = line.strip()
227+
228+
else:
229+
# Check for errors and raise exception.
230+
error = ""
231+
while not xvfb.stderr.at_eof():
232+
line = await xvfb.stderr.readline()
233+
error += line.decode("utf-8")
234+
235+
await xvfb.wait()
236+
raise Exception(f"Unable to start the Xvfb process: \n {error}")
237+
238+
# Close the read and write descriptors.
239+
os.close(read_descriptor)
240+
os.close(write_descriptor)
241+
242+
return xvfb, display_port

tests/test_app.py

Lines changed: 5 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# Copyright 2021 The MathWorks, Inc.
22

3-
from jupyter_matlab_proxy.devel import xvfb
43
import pytest, asyncio, aiohttp, os, json, psutil, socket, subprocess, time, requests
54
from unittest.mock import patch
65
from aiohttp import web
@@ -138,74 +137,32 @@ def test_marshal_error(actual_error, expected_error):
138137

139138

140139
class FakeServer:
141-
"""Context Manager class which returns a web server wrapped by aiohttp_client
142-
for sending HTTP requests during testing.
143-
144-
executes the remove_zombie_matlab_process() method before starting the server
145-
and after shutting it down so as to clear out any
140+
"""Context Manager class which returns a web server wrapped in aiohttp_client pytest fixture
141+
for testing.
146142
"""
147143

148144
def __init__(self, loop, aiohttp_client):
149145
self.loop = loop
150146
self.aiohttp_client = aiohttp_client
151-
self.pretest_dev_processes = None
152-
self.posttest_dev_processes = None
153-
self.zombie_dev_processes = None
154147

155148
def __enter__(self):
156-
157-
self.pretest_dev_processes = set(self.gather_running_dev_processes())
158-
159-
# self.patcher = patch(
160-
# "jupyter_matlab_proxy.app_state.AppState.reserve_matlab_port",
161-
# new=MockServerPort.mock_reserve_port,
162-
# )
163-
164-
# self.patcher.start()
165-
166149
self.server = app.create_app()
167150
self.runner = web.AppRunner(self.server)
168-
169151
self.loop.run_until_complete(self.runner.setup())
170152

171153
self.site = web.TCPSite(
172154
self.runner,
173155
host=self.server["settings"]["host_interface"],
174156
port=self.server["settings"]["app_port"],
175157
)
176-
177158
self.loop.run_until_complete(self.site.start())
159+
178160
return self.loop.run_until_complete(self.aiohttp_client(self.server))
179161

180162
def __exit__(self, exc_type, exc_value, exc_traceback):
181-
182163
self.loop.run_until_complete(self.runner.shutdown())
183164
self.loop.run_until_complete(self.runner.cleanup())
184165

185-
# self.patcher.stop()
186-
187-
self.posttest_dev_processes = set(self.gather_running_dev_processes())
188-
self.zombie_dev_processes = (
189-
self.posttest_dev_processes - self.pretest_dev_processes
190-
)
191-
192-
for process in self.zombie_dev_processes:
193-
process.terminate()
194-
195-
gone, alive = psutil.wait_procs(self.zombie_dev_processes)
196-
197-
for process in alive:
198-
process.kill()
199-
200-
def gather_running_dev_processes(self):
201-
running_dev_processes = []
202-
for process in psutil.process_iter(["pid", "name"]):
203-
cmd = process.cmdline()
204-
if len(cmd) > 3 and "devel.py" in cmd[2]:
205-
running_dev_processes.append(process)
206-
207-
return running_dev_processes
208-
209166

210167
@pytest.fixture(name="test_server")
211168
def test_server_fixture(
@@ -255,11 +212,6 @@ async def test_start_matlab_route(test_server):
255212
assert resp.status == 200
256213

257214
resp_json = json.loads(await resp.text())
258-
# print(test_server.app["state"].settings)
259-
# print(resp)
260-
# print(resp_json)
261-
# break
262-
263215
if resp_json["matlab"]["status"] == "up":
264216
break
265217
else:
@@ -336,7 +288,8 @@ async def test_matlab_proxy_404(proxy_payload, test_server):
336288

337289
headers = {"content-type": "application/json"}
338290

339-
# Request a non-existing html file. Request gets proxied to app.matlab_view() which should raise HTTPNotFound() exception ie. return HTTP status code 404
291+
# Request a non-existing html file.
292+
# Request gets proxied to app.matlab_view() which should raise HTTPNotFound() exception ie. return HTTP status code 404
340293
resp = await test_server.post(
341294
"./1234.html", data=json.dumps(proxy_payload), headers=headers
342295
)

0 commit comments

Comments
 (0)