Jul 01, 2020
On a recent project, I got the opportunity to create a desktop application using Electron, React and Go. Electron served as a tool to bundle the React app and the Go server in a neat little installable package. It also took care of starting the Go server as a subprocess when the app launched.
The included Go server performed a few dependency checks at each startup by trying to find some particular executables in the PATH
.
func checkDependency(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
This worked great during the development period, but once I built an installable package and installed the app, these dependency checks failed every time I launched it.
Curiously, the checks only failed only on MacOS and Linux, not on Windows.
Even more curiously, launching the installed app through a terminal seemingly fixed this problem.
The reason for this behaviour, as I found out after several hours of painful debugging, was unexpected inheritance of the PATH
environment variable.
Inheritance of Environment Variables
A newly spawned child process inherits its parent’s environment variables.
To demonstrate this, let’s build a couple of Go programs: envReader
and envParent
.
envReader
reads a special environment variable “SpecialEnv” and prints its value, or “<Not Found>” if it is not set.
// envReader
func main() {
val := os.Getenv("SpecialEnv")
if val == "" {
val = "<Not Found>"
}
fmt.Printf("%d: SpecialEnv: %s", os.Getpid(), val)
}
envParent
, runs envReader
as a subprocess twice, with the “SpecialEnv” set only in the second invocation.
// envParent
func main() {
fmt.Println("Before setting special env:")
runChild()
fmt.Println("Setting SpecialEnv in parent")
os.Setenv("SpecialEnv", "SpecialValue")
fmt.Println("After setting special env:")
runChild()
}
func runChild() {
child := exec.Command("envReader")
output, _ := child.Output() // Error handling omitted for brevity
fmt.Println(string(output))
}
This produces the following output:
Before setting special env:
62231: SpecialEnv: <Not Found>
Setting SpecialEnv in parent
After setting special env:
62232: SpecialEnv: SpecialValue
In the first invocation, “SpecialEnv” is not set and hence the produced output contains “<Not Found>”. After this, the parent calls os.Setenv
and sets a value to it. Note that this value is not exported, so it is local to the envParent
processes only1. Yet, the second invocation of the child process finds it successfully.
This proves that child processes inherit their parent’s environment variables.
Inherited PATH outside the Terminal
During development, it is common to launch Electron apps using the electron
command from a terminal. This creates a new process and starts the app. Since this process is spawned from a terminal shell, it inherits all the environment variables of the shell session. This includes any custom variables defined in shell config files such as .bashrc
and .bash_profile
, like a custom PATH
.
Once the app is shipped, consumers don’t use the terminal to launch it. They use GUI icons, which means the app process is not created by a terminal, but by the Desktop Environment.
The desktop environment process does not concern itself with custom configuration files such as .bash_profile
, hence its version of the PATH
environment variable is terse. It does not contain any custom directories the user might have specified in their shell config files.
To show this in action, I wrote a small app with fyne which shows the PATH
value of the current process. You can find the source here, if interested.
Here’s what it looks like when launched from the GUI:
And here it is when launched from the terminal:
Clearly, we’re missing out on a lot when our app is launched from the GUI.
(Also, I really need to cleanup my PATH config)
Finding the correct PATH
Fortunately, this problem is easy to solve. With the help of the user’s default shell, we can find the complete PATH with the env
command.
- All processes have access to the
SHELL
environment variable, which contains the location of the default shell executable (eg, “/bin/bash”) - Most shells support the
env
command to list all the environment variables they have access to - Therefore, running the default shell in an interactive, login mode2 with the
env
command should give us, among other variables, the complete PATH.
The Go implementation of this is not too complicated:
func fixPath() {
// Find the default shell
defaultShell := os.Getenv("SHELL")
// Prepare command to get all environment variables
// eg. /bin/bash -ilc env
envCommand := exec.Command(defaultShell, "-ilc", "env")
allEnvVars, _ := envCommand.Output()
// Find the PATH variable
for _, envVar := range strings.Split(string(allEnvVars), "\n") {
if strings.HasPrefix(envVar, "PATH") {
currentPath := os.Getenv("PATH")
// Append retrieved PATH to existing value, to get the complete PATH
completePath := currentPath + string(os.PathListSeparator) + envVar
// Set the current process's PATH to the complete PATH
os.Setenv("PATH", completePath)
return
}
}
}
I have created a small Go package which do this, and a little more: pathfix.
To use it, add it to your project with go get github.com/haroldadmin/pathfix
, and then call the Fix
method to fix your process’s PATH:
pathfix.Fix()
Here it is in action, using the app shown earlier.
Before the fix:
After the fix:
If you need to do it in Electron using NodeJS, there is a handy package fix-path for it.
Some Notes
-
After fixing this problem, I wondered why didn’t I encounter this error on Windows. I suspect that Environment Variables on Windows are global by default, and hence every process receives the same
PATH
value every time. I might be wrong about this, please feel free to correct me. -
I also stumbled across PAM Environment Variables which are apparently a more “correct” way of solving this on Linux. You can read more about them in the documentation and the Arch Wiki. The gist of it is that you need to define your environment variables in a
pam_env
file, but the syntax for it is different. I would be grateful if someone took the time out to explain their usage in a more approachable manner. -
Another potential solution for Linux users is to define their environment variables in the
~/.profile
file, which is sourced by the OS shell on every login. However, just like with thepam_env
solution, this puts the burden of configuration on your users instead of you.
Question, comments or feedback? Feel free to reach out to me on Twitter @haroldadmin
Footnotes
-
Bash environment variables should be declared with
export
to make them available to other processes. eg.,export PATH=$PATH:~/Android/Sdk/platform-tools/
↩ -
Using the
-il
options when launching the shell makes sure that it reads common config files, such as*.rc
and*.profile
. The-c
at the end just makes sure that the shell reads only theenv
command, and nothing more. Read more about interactive, non-interactive, login and non-login shells here ↩