Repository: spack/spack

Issue:
Spack freezes on specific typo in recipe
### Steps to reproduce

For example, with this specific mistake in a recipe:

```console
$ spack edit lsof
# CHANGE
    def install(self, spec, prefix):
# TO
    def install(self, invalid):

$ spack install lsof
==> Installing lsof-4.91-g34ogep5avzxz4vio7vwfvcexoct3pfo [1/1]
==> No binary for lsof-4.91-g34ogep5avzxz4vio7vwfvcexoct3pfo found: installing from source
==> Using cached archive: /.../spack/var/spack/cache/_source-cache/archive/3c/3ca57887470fdf223a8e8aae4559cd3a26787bea93b94336c90ee8062e29e352.tar.gz
==> No patches needed for lsof
==> lsof: Executing phase: 'install'
Process Process-1:
Traceback (most recent call last):
  File "/.../spack/lib/spack/spack/build_environment.py", line 1050, in _setup_pkg_and_run
    return_value = function(pkg, kwargs)
  File "/.../spack/lib/spack/spack/installer.py", line 2510, in build_process
    return installer.run()
  File "/.../spack/lib/spack/spack/installer.py", line 2375, in run
    self._real_install()
  File "/.../spack/lib/spack/spack/installer.py", line 2470, in _real_install
    phase_fn.execute()
  File "/.../spack/lib/spack/spack/builder.py", line 433, in execute
    self.phase_fn(pkg, pkg.spec, pkg.prefix)
  File "/.../spack/lib/spack/spack/builder.py", line 309, in _adapter
    return phase_fn(spec, prefix)
TypeError: Lsof.install() takes 1 positional argument but 3 were given

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/.../spack/opt/spack/linux-ubuntu22.04-skylake/gcc-11.3.0/python-3.10.10-ahhxrmd6fzjqjcvnf3ct5inkowfamkyg/lib/python3.10/multiprocessing/process.py", line 314, in _bootstrap
    self.run()
  File "/.../spack/opt/spack/linux-ubuntu22.04-skylake/gcc-11.3.0/python-3.10.10-ahhxrmd6fzjqjcvnf3ct5inkowfamkyg/lib/python3.10/multiprocessing/process.py", line 108, in run
    self._target(*self._args, **self._kwargs)
  File "/.../spack/lib/spack/spack/build_environment.py", line 1067, in _setup_pkg_and_run
    package_context = get_package_context(tb)
  File "/.../spack/lib/spack/spack/build_environment.py", line 1278, in get_package_context
    if isinstance(obj, CONTEXT_BASES) and typename not in basenames:
UnboundLocalError: local variable 'typename' referenced before assignment

... freeze ...
```




### Error message

Spack does print the error but the command freezes instead of exit with a failure.

**Note:** This actually **revealed 2 issues**:

1. A bug in boiler plate code of child process error handling code (typename UnboundLocalError)

2. A bug in child process unexpected exit handling (the freeze/infinite wait)

### Information on your system

* **Spack:** 0.21.0.dev0 (ae08b25dacd56b287d8c80ad648b998cfa2ec8cb)
* **Python:** 3.10.10
* **Platform:** linux-ubuntu22.04-skylake
* **Concretizer:** clingo

### General information

- [X] I have run `spack debug report` and reported the version of Spack/Python/Platform
- [X] I have searched the issues of this repo and believe this is not a duplicate
- [X] I have run the failing commands in debug mode and reported the output


Discussion:

Resolving patch:
diff --git a/lib/spack/spack/build_environment.py b/lib/spack/spack/build_environment.py
--- a/lib/spack/spack/build_environment.py
+++ b/lib/spack/spack/build_environment.py
@@ -1027,7 +1027,7 @@ def get_cmake_prefix_path(pkg):
 
 
 def _setup_pkg_and_run(
-    serialized_pkg, function, kwargs, child_pipe, input_multiprocess_fd, jsfd1, jsfd2
+    serialized_pkg, function, kwargs, write_pipe, input_multiprocess_fd, jsfd1, jsfd2
 ):
     context = kwargs.get("context", "build")
 
@@ -1048,12 +1048,12 @@ def _setup_pkg_and_run(
                 pkg, dirty=kwargs.get("dirty", False), context=context
             )
         return_value = function(pkg, kwargs)
-        child_pipe.send(return_value)
+        write_pipe.send(return_value)
 
     except StopPhase as e:
         # Do not create a full ChildError from this, it's not an error
         # it's a control statement.
-        child_pipe.send(e)
+        write_pipe.send(e)
     except BaseException:
         # catch ANYTHING that goes wrong in the child process
         exc_type, exc, tb = sys.exc_info()
@@ -1102,10 +1102,10 @@ def _setup_pkg_and_run(
             context,
             package_context,
         )
-        child_pipe.send(ce)
+        write_pipe.send(ce)
 
     finally:
-        child_pipe.close()
+        write_pipe.close()
         if input_multiprocess_fd is not None:
             input_multiprocess_fd.close()
 
@@ -1149,7 +1149,7 @@ def child_fun():
     For more information on `multiprocessing` child process creation
     mechanisms, see https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
     """
-    parent_pipe, child_pipe = multiprocessing.Pipe()
+    read_pipe, write_pipe = multiprocessing.Pipe(duplex=False)
     input_multiprocess_fd = None
     jobserver_fd1 = None
     jobserver_fd2 = None
@@ -1174,7 +1174,7 @@ def child_fun():
                 serialized_pkg,
                 function,
                 kwargs,
-                child_pipe,
+                write_pipe,
                 input_multiprocess_fd,
                 jobserver_fd1,
                 jobserver_fd2,
@@ -1183,6 +1183,12 @@ def child_fun():
 
         p.start()
 
+        # We close the writable end of the pipe now to be sure that p is the
+        # only process which owns a handle for it. This ensures that when p
+        # closes its handle for the writable end, read_pipe.recv() will
+        # promptly report the readable end as being ready.
+        write_pipe.close()
+
     except InstallError as e:
         e.pkg = pkg
         raise
@@ -1192,7 +1198,16 @@ def child_fun():
         if input_multiprocess_fd is not None:
             input_multiprocess_fd.close()
 
-    child_result = parent_pipe.recv()
+    def exitcode_msg(p):
+        typ = "exit" if p.exitcode >= 0 else "signal"
+        return f"{typ} {abs(p.exitcode)}"
+
+    try:
+        child_result = read_pipe.recv()
+    except EOFError:
+        p.join()
+        raise InstallError(f"The process has stopped unexpectedly ({exitcode_msg(p)})")
+
     p.join()
 
     # If returns a StopPhase, raise it
@@ -1212,6 +1227,10 @@ def child_fun():
         child_result.print_context()
         raise child_result
 
+    # Fallback. Usually caught beforehand in EOFError above.
+    if p.exitcode != 0:
+        raise InstallError(f"The process failed unexpectedly ({exitcode_msg(p)})")
+
     return child_result
 
 


Test patch: