util: redirect stderr to stdout to spawn fewer threads; reuse BaseOutputReader (IDEA...
[idea/community.git] / platform / util / src / com / intellij / util / EnvironmentUtil.java
1 /*
2  * Copyright 2000-2015 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.intellij.util;
17
18 import com.intellij.execution.process.UnixProcessManager;
19 import com.intellij.openapi.application.PathManager;
20 import com.intellij.openapi.diagnostic.Logger;
21 import com.intellij.openapi.util.AtomicNotNullLazyValue;
22 import com.intellij.openapi.util.NotNullLazyValue;
23 import com.intellij.openapi.util.SystemInfo;
24 import com.intellij.openapi.util.io.FileUtil;
25 import com.intellij.openapi.util.registry.Registry;
26 import com.intellij.openapi.util.text.StringUtil;
27 import com.intellij.openapi.vfs.CharsetToolkit;
28 import com.intellij.util.concurrency.FixedFuture;
29 import com.intellij.util.io.BaseOutputReader;
30 import com.intellij.util.text.CaseInsensitiveStringHashingStrategy;
31 import gnu.trove.THashMap;
32 import org.jetbrains.annotations.NotNull;
33 import org.jetbrains.annotations.Nullable;
34 import org.jetbrains.annotations.TestOnly;
35
36 import java.io.*;
37 import java.nio.charset.Charset;
38 import java.util.*;
39 import java.util.concurrent.*;
40
41 import static java.util.Collections.unmodifiableMap;
42
43 public class EnvironmentUtil {
44   private static final Logger LOG = Logger.getInstance("#com.intellij.util.EnvironmentUtil");
45
46   private static final int SHELL_ENV_READING_TIMEOUT = 20000;
47
48   private static final String LANG = "LANG";
49   private static final String LC_ALL = "LC_ALL";
50   private static final String LC_CTYPE = "LC_CTYPE";
51
52   private static final Future<Map<String, String>> ourEnvGetter;
53   static {
54     if (SystemInfo.isMac && "unlocked".equals(System.getProperty("__idea.mac.env.lock")) && Registry.is("idea.fix.mac.env")) {
55       ExecutorService executor = Executors.newSingleThreadExecutor(ConcurrencyUtil.newNamedThreadFactory("Shell Env Loader"));
56       ourEnvGetter = executor.submit(new Callable<Map<String, String>>() {
57         @Override
58         public Map<String, String> call() throws Exception {
59           return unmodifiableMap(setCharsetVar(getShellEnv()));
60         }
61       });
62       executor.shutdown();
63     }
64     else {
65       ourEnvGetter = new FixedFuture<Map<String, String>>(getSystemEnv());
66     }
67   }
68
69   private static final NotNullLazyValue<Map<String, String>> ourEnvironment = new AtomicNotNullLazyValue<Map<String, String>>() {
70     @NotNull
71     @Override
72     protected Map<String, String> compute() {
73       try {
74         return ourEnvGetter.get();
75       }
76       catch (Throwable t) {
77         LOG.warn("can't get shell environment", t);
78         return getSystemEnv();
79       }
80     }
81   };
82
83   private static Map<String, String> getSystemEnv() {
84     if (SystemInfo.isWindows) {
85       return unmodifiableMap(new THashMap<String, String>(System.getenv(), CaseInsensitiveStringHashingStrategy.INSTANCE));
86     }
87     else {
88       return System.getenv();
89     }
90   }
91
92   private EnvironmentUtil() { }
93
94   public static boolean isEnvironmentReady() {
95     return ourEnvGetter.isDone();
96   }
97
98   /**
99    * A wrapper layer around {@link System#getenv()}.
100    * <p>
101    * On Windows, the returned map is case-insensitive (i.e. {@code map.get("Path") == map.get("PATH")} holds).
102    * <p>
103    * On Mac OS X things are complicated.<br/>
104    * An app launched by a GUI launcher (Finder, Dock, Spotlight etc.) receives a pretty empty and useless environment,
105    * since standard Unix ways of setting variables via e.g. ~/.profile do not work. What's more important, there are no
106    * sane alternatives. This causes a lot of user complaints about tools working in a terminal not working when launched
107    * from the IDE. To ease their pain, the IDE loads a shell environment (see {@link #getShellEnv()} for gory details)
108    * and returns it as the result.<br/>
109    * And one more thing (c): locale variables on OS X are usually set by a terminal app - meaning they are missing
110    * even from a shell environment above. This again causes user complaints about tools being unable to output anything
111    * outside ASCII range when launched from the IDE. Resolved by adding LC_CTYPE variable to the map if it doesn't contain
112    * explicitly set locale variables (LANG/LC_ALL/LC_CTYPE). See {@link #setCharsetVar(Map)} for details.
113    *
114    * @return unmodifiable map of the process environment.
115    */
116   @NotNull
117   public static Map<String, String> getEnvironmentMap() {
118     return ourEnvironment.getValue();
119   }
120
121   /**
122    * Same as {@code getEnvironmentMap().get(name)}.
123    * Returns value for the passed environment variable name, or null if no such variable found.
124    *
125    * @see #getEnvironmentMap()
126    */
127   @Nullable
128   public static String getValue(@NotNull String name) {
129     return getEnvironmentMap().get(name);
130   }
131
132   /**
133    * Same as {@code flattenEnvironment(getEnvironmentMap())}.
134    * Returns an environment as an array of "NAME=VALUE" strings.
135    *
136    * @see #getEnvironmentMap()
137    */
138   public static String[] getEnvironment() {
139     return flattenEnvironment(getEnvironmentMap());
140   }
141
142   public static String[] flattenEnvironment(Map<String, String> environment) {
143     String[] array = new String[environment.size()];
144     int i = 0;
145     for (Map.Entry<String, String> entry : environment.entrySet()) {
146       array[i++] = entry.getKey() + "=" + entry.getValue();
147     }
148     return array;
149   }
150
151   private static Map<String, String> getShellEnv() throws Exception {
152     String shell = System.getenv("SHELL");
153     if (shell == null || !new File(shell).canExecute()) {
154       throw new Exception("shell:" + shell);
155     }
156
157     File reader = FileUtil.findFirstThatExist(
158       PathManager.getBinPath() + "/printenv.py",
159       PathManager.getHomePath() + "/community/bin/mac/printenv.py",
160       PathManager.getHomePath() + "/bin/mac/printenv.py"
161     );
162     if (reader == null) {
163       throw new Exception("bin:" + PathManager.getBinPath());
164     }
165
166     File envFile = FileUtil.createTempFile("intellij-shell-env.", ".tmp", false);
167     try {
168       String[] command = {shell, "-l", "-i", "-c", ("'" + reader.getAbsolutePath() + "' '" + envFile.getAbsolutePath() + "'")};
169       LOG.info("loading shell env: " + StringUtil.join(command, " "));
170
171       ProcessBuilder processBuilder = new ProcessBuilder(command);
172       processBuilder.redirectErrorStream(true);
173       Process process = processBuilder.start();
174
175       StreamGobbler gobbler = new StreamGobbler(process.getInputStream());
176       int rv = waitAndTerminateAfter(process, SHELL_ENV_READING_TIMEOUT);
177       gobbler.stop();
178
179       String lines = FileUtil.loadFile(envFile);
180       if (rv != 0 || lines.isEmpty()) {
181         LOG.info("shell process output: " + StringUtil.trimEnd(gobbler.getText(), '\n'));
182         throw new Exception("rv:" + rv + " text:" + lines.length());
183       }
184       return parseEnv(lines);
185     }
186     finally {
187       FileUtil.delete(envFile);
188     }
189   }
190
191   private static Map<String, String> parseEnv(String text) throws Exception {
192     Set<String> toIgnore = new HashSet<String>(Arrays.asList("_", "PWD", "SHLVL"));
193     Map<String, String> env = System.getenv();
194     Map<String, String> newEnv = new HashMap<String, String>();
195
196     String[] lines = text.split("\0");
197     for (String line : lines) {
198       int pos = line.indexOf('=');
199       if (pos <= 0) {
200         throw new Exception("malformed:" + line);
201       }
202       String name = line.substring(0, pos);
203       if (!toIgnore.contains(name)) {
204         newEnv.put(name, line.substring(pos + 1));
205       }
206       else if (env.containsKey(name)) {
207         newEnv.put(name, env.get(name));
208       }
209     }
210
211     LOG.info("shell environment loaded (" + newEnv.size() + " vars)");
212     return newEnv;
213   }
214
215   private static int waitAndTerminateAfter(@NotNull Process process, int timeoutMillis) {
216     Integer exitCode = waitFor(process, timeoutMillis);
217     if (exitCode != null) {
218       return exitCode;
219     }
220     LOG.warn("shell env loader is timed out");
221     UnixProcessManager.sendSigIntToProcessTree(process);
222     exitCode = waitFor(process, 1000);
223     if (exitCode != null) {
224       return exitCode;
225     }
226     LOG.warn("failed to terminate shell env loader process gracefully, terminating forcibly");
227     UnixProcessManager.sendSigKillToProcessTree(process);
228     exitCode = waitFor(process, 1000);
229     if (exitCode != null) {
230       return exitCode;
231     }
232     LOG.warn("failed to kill shell env loader");
233     return -1;
234   }
235
236   @Nullable
237   private static Integer waitFor(@NotNull Process process, int timeoutMillis) {
238     long stop = System.currentTimeMillis() + timeoutMillis;
239     while (System.currentTimeMillis() < stop) {
240       TimeoutUtil.sleep(100);
241       try {
242         return process.exitValue();
243       }
244       catch (IllegalThreadStateException ignore) { }
245     }
246     return null;
247   }
248
249   private static Map<String, String> setCharsetVar(@NotNull Map<String, String> env) {
250     if (!isCharsetVarDefined(env)) {
251       Locale locale = Locale.getDefault();
252       Charset charset = CharsetToolkit.getDefaultSystemCharset();
253       String language = locale.getLanguage();
254       String country = locale.getCountry();
255       String value = (language.isEmpty() || country.isEmpty() ? "en_US" : language + '_' + country) + '.' + charset.name();
256       env.put(LC_CTYPE, value);
257       LOG.info("LC_CTYPE=" + value);
258     }
259     return env;
260   }
261
262   private static boolean isCharsetVarDefined(@NotNull Map<String, String> env) {
263     return !env.isEmpty() && (env.containsKey(LANG) || env.containsKey(LC_ALL) || env.containsKey(LC_CTYPE));
264   }
265
266   public static void inlineParentOccurrences(@NotNull Map<String, String> envs) {
267     Map<String, String> parentParams = new HashMap<String, String>(System.getenv());
268     for (Map.Entry<String, String> entry : envs.entrySet()) {
269       String key = entry.getKey();
270       String value = entry.getValue();
271       if (value != null) {
272         String parentVal = parentParams.get(key);
273         if (parentVal != null && containsEnvKeySubstitution(key, value)) {
274           envs.put(key, value.replace("$" + key + "$", parentVal));
275         }
276       }
277     }
278   }
279
280   private static boolean containsEnvKeySubstitution(final String envKey, final String val) {
281     return ArrayUtil.find(val.split(File.pathSeparator), "$" + envKey + "$") != -1;
282   }
283
284   @TestOnly
285   static Map<String, String> testLoader() {
286     try {
287       return getShellEnv();
288     }
289     catch (Exception e) {
290       throw new RuntimeException(e);
291     }
292   }
293
294   @TestOnly
295   static Map<String, String> testParser(@NotNull String lines) {
296     try {
297       return parseEnv(lines);
298     }
299     catch (Exception e) {
300       throw new RuntimeException(e);
301     }
302   }
303
304   private static class StreamGobbler extends BaseOutputReader {
305
306     private final StringBuffer myBuffer;
307
308     public StreamGobbler(@NotNull InputStream stream) {
309       super(stream, CharsetToolkit.getDefaultSystemCharset());
310       myBuffer = new StringBuffer();
311       start("stdout/stderr streams of shell env loading process");
312     }
313
314     @NotNull
315     @Override
316     protected Future<?> executeOnPooledThread(@NotNull Runnable runnable) {
317       ExecutorService executor = ConcurrencyUtil.newSingleThreadExecutor("shell process streams gobbler");
318       Future<?> future = executor.submit(runnable);
319       executor.shutdown();
320       return future;
321     }
322
323     @Override
324     protected void onTextAvailable(@NotNull String text) {
325       myBuffer.append(text);
326     }
327
328     @NotNull
329     public String getText() {
330       return myBuffer.toString();
331     }
332   }
333 }