11"""The local STEMMUS_SCOPE model process wrapper."""
22import os
3+ import platform
34import subprocess
45from pathlib import Path
6+ from time import sleep
57from typing import Union
8+ from PyStemmusScope .bmi .utils import MATLAB_ERROR
9+ from PyStemmusScope .bmi .utils import PROCESS_READY
10+ from PyStemmusScope .bmi .utils import MatlabError
611from PyStemmusScope .config_io import read_config
712
813
9- def is_alive (process : Union [subprocess .Popen , None ]) -> subprocess .Popen :
14+ def alive_process (process : Union [subprocess .Popen , None ]) -> subprocess .Popen :
1015 """Return process if the process is alive, raise an exception if it is not."""
1116 if process is None :
1217 msg = "Model process does not seem to be open."
@@ -17,12 +22,39 @@ def is_alive(process: Union[subprocess.Popen, None]) -> subprocess.Popen:
1722 return process
1823
1924
20- def wait_for_model (process : subprocess .Popen , phrase = b"Select BMI mode:" ) -> None :
25+ def read_stdout (process : subprocess .Popen ) -> bytes :
26+ """Read from stdout. If the stream ends unexpectedly, an error is raised."""
27+ assert process .stdout is not None # required for type narrowing.
28+ read = process .stdout .read (1 )
29+ if read is None :
30+ sleep (5 )
31+ read = process .stdout .read (1 )
32+ if read is not None :
33+ return bytes (read )
34+ msg = "Connection error: could not find expected output or "
35+ raise ConnectionError (msg )
36+ return bytes (read )
37+
38+
39+ def _model_is_ready (process : subprocess .Popen ) -> None :
40+ return _wait_for_model (PROCESS_READY , process )
41+
42+
43+ def _wait_for_model (phrase : bytes , process : subprocess .Popen ) -> None :
2144 """Wait for model to be ready for interaction."""
2245 output = b""
23- while is_alive (process ) and phrase not in output :
24- assert process .stdout is not None # required for type narrowing.
25- output += bytes (process .stdout .read (1 ))
46+
47+ while alive_process (process ) and phrase not in output :
48+ output += read_stdout (process )
49+ if MATLAB_ERROR in output :
50+ try :
51+ process .terminate ()
52+ finally :
53+ msg = (
54+ "Error encountered in Matlab.\n "
55+ "Please inspect logs in the output directory"
56+ )
57+ raise MatlabError (msg )
2658
2759
2860def find_exe (config : dict ) -> str :
@@ -51,46 +83,73 @@ def __init__(self, cfg_file: str) -> None:
5183 exe_file = find_exe (config )
5284 args = [exe_file , cfg_file , "bmi" ]
5385
54- os .environ ["MATLAB_LOG_DIR" ] = str (config ["InputPath" ])
55-
56- self .matlab_process = subprocess .Popen (
86+ lib_path = os .getenv ("LD_LIBRARY_PATH" )
87+ if lib_path is None :
88+ msg = (
89+ "Environment variable LD_LIBRARY_PATH not found. "
90+ "Refer the Matlab Compiler Runtime documentation"
91+ )
92+ raise ValueError (msg )
93+
94+ # Ensure output directory exists so log file can be written:
95+ Path (config ["OutputPath" ]).mkdir (parents = True , exist_ok = True )
96+ env = {
97+ "LD_LIBRARY_PATH" : lib_path ,
98+ "MATLAB_LOG_DIR" : str (config ["OutputPath" ]),
99+ }
100+
101+ self .process = subprocess .Popen (
57102 args ,
58103 stdin = subprocess .PIPE ,
59104 stdout = subprocess .PIPE ,
60105 bufsize = 0 ,
106+ env = env ,
61107 )
62108
63- wait_for_model (self .matlab_process )
109+ if platform .system () == "Linux" :
110+ assert self .process .stdout is not None # required for type narrowing.
111+ # Make the connection non-blocking to allow for a timeout on read.
112+ os .set_blocking (self .process .stdout .fileno (), False )
113+ else :
114+ msg = "Unexpected system. The executable is only compiled for Linux."
115+ raise ValueError (msg )
116+ _model_is_ready (self .process )
64117
65118 def is_alive (self ) -> bool :
66119 """Return if the process is alive."""
67120 try :
68- is_alive (self .matlab_process )
121+ alive_process (self .process )
69122 return True
70123 except ConnectionError :
71124 return False
72125
73126 def initialize (self ) -> None :
74127 """Initialize the model and wait for it to be ready."""
75- self .matlab_process = is_alive (self .matlab_process )
128+ self .process = alive_process (self .process )
76129
77- self .matlab_process .stdin .write ( # type: ignore
130+ self .process .stdin .write ( # type: ignore
78131 bytes (f'initialize "{ self .cfg_file } "\n ' , encoding = "utf-8" )
79132 )
80- wait_for_model (self .matlab_process )
133+ _model_is_ready (self .process )
81134
82135 def update (self ) -> None :
83136 """Update the model and wait for it to be ready."""
84- if self .matlab_process is None :
137+ if self .process is None :
85138 msg = "Run initialize before trying to update the model."
86139 raise AttributeError (msg )
87140
88- self .matlab_process = is_alive (self .matlab_process )
89- self .matlab_process .stdin .write (b"update\n " ) # type: ignore
90- wait_for_model (self .matlab_process )
141+ self .process = alive_process (self .process )
142+ self .process .stdin .write (b"update\n " ) # type: ignore
143+ _model_is_ready (self .process )
91144
92145 def finalize (self ) -> None :
93146 """Finalize the model."""
94- self .matlab_process = is_alive (self .matlab_process )
95- self .matlab_process .stdin .write (b"finalize\n " ) # type: ignore
96- wait_for_model (self .matlab_process , phrase = b"Finished clean up." )
147+ self .process = alive_process (self .process )
148+ self .process .stdin .write (b"finalize\n " ) # type: ignore
149+ sleep (10 )
150+ if self .process .poll () != 0 :
151+ try :
152+ self .process .terminate ()
153+ finally :
154+ msg = f"Model terminated with return code { self .process .poll ()} "
155+ raise ValueError (msg )
0 commit comments