Jul 01, 2020

Hero

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:

PATH when launched from GUI

And here it is when launched from the terminal:

PATH when launched from 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:

Before fix

After the fix:

After 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 the pam_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

  1. Bash environment variables should be declared with export to make them available to other processes. eg., export PATH=$PATH:~/Android/Sdk/platform-tools/

  2. 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 the env command, and nothing more. Read more about interactive, non-interactive, login and non-login shells here