Skip to content

bootstrapping: bootstrap spack dependencies (executable and python module)#20207

Closed
becker33 wants to merge 1 commit intodevelopfrom
features/detect-and-swap-current-python
Closed

bootstrapping: bootstrap spack dependencies (executable and python module)#20207
becker33 wants to merge 1 commit intodevelopfrom
features/detect-and-swap-current-python

Conversation

@becker33
Copy link
Copy Markdown
Member

@becker33 becker33 commented Dec 2, 2020

This PR allows Spack to search for executables and modules, and if requested install them, to satisfy its own dependencies. It ensures that the bootstrapped package is built with the python under which Spack is running, to ensure compatibility for python modules and to speed up installs in both cases.

Search order:
1. sys.path for modules, PATH for executables
2. installed packages
3. install it (optional)

So far, this is implemented for the clingo python module and the flake8 executable.'

As part of this PR, I had to fix our PythonPackage class to be able to install against system python on MacOS. @adamjstewart do those changes look acceptable to you?

@tgamblin @alalazo @cosmicexplorer

This currently has rough edges, TODO's include appropriate error messages, testing.

@becker33 becker33 added the WIP label Dec 2, 2020

python_pkg = spec['python'].package
args += ['--root=%s' % prefix,
'--install-lib=%s' % python_pkg.site_packages_dir,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On some systems, Python libraries are automatically installed to prefix.lib64. This will force them to install to prefix.lib. I'm not sure if that will break things or not, but it's something to be aware of.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adamjstewart do you know which systems? I'll spin up a container and see if I can break things.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've seen reports of this on opensuse, fedora, and rhel: #18520, #17126, #19546

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, so I think what we really need to be using is distutils.sysconfig.get_python_lib(True, prefix=prefix). That will give the platform-specific directory (which is generally used for compiled code) which is the more restrictive.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, should we make the attribute in the Python package more robust?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I think this is fixed now, and handled "properly"


def flake8(parser, args):
flake8 = which('flake8', required=True)
flake8 = get_executable('flake8', spec='py-flake8', install=True)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We currently use which in a lot of places in Spack. All of those places could benefit from that functionality. Would it be possible to instead add a install=True arg to which much like working_dir has a create=True arg? Or would that lead to unsolvable circular import issues?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm torn about moving this functionality into util/executable.py. It seems most useful there, but the goal with util.* is that they don't need to import from spack.*, and this definitely needs spack.spec and spack.store. I think this is something that should be discussed more before merging.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One possibility here is to use something like https://github.com/pantsbuild/pex to separate the python environment for flake8 from the rest of spack. I had started on making a PexPackage base class which would have made this relatively easy, I will evaluate whether that would be useful here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't actually want the python environment separated (or at least I'm pretty sure I don't). I'm relying on the fact that we know of one working python on the system (namely, the one Spack is running in) to save huge amounts of build time. If we bootstrap flake8 without relying on the current python executable, then we have to pay the cost of building (or downloading a binary for) python and all of its dependencies. Why bother with that when we know where python is?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, when I said "python environment" I was referring to the set of installed packages. If we were to generate any PEX files e.g. for flake8, we would definitely just point pex at the resolved python executable (pex emphatically will never try to build python, it will only search for interpreters).

The reason this would come in handy is if e.g. flake8 specifies a package that conflicts with coverage or something. It would mean that we could create executables like flake8 without modifying the python environment (and therefore the PEX file would stay cached).

spec = spack.spec.Spec(spec or module)

# We have to run as part of this python
spec.constrain('^python@%d.%d' % sys.version_info[:2])
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this syntax actually work? I know we've had a lot of problems with X.Y vs X.Y.Z in the past. That's why we've always had to do [email protected]:2.7.999

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. It works for queries, but not for building because it reads as concrete.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait no this should be fine -- it's used as a query, where it doesn't cause a problem, and it's used in a context manager in which the current python is an external and python isn't buildable. So there isn't the problem of concretizing to a partial version. I'll add a comment to the code to that effect though, since the reason it works is a little subtle.

Copy link
Copy Markdown
Contributor

@cosmicexplorer cosmicexplorer Dec 7, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just relatedly, I had tried to introduce a new version syntax e.g. 2.6.* to give people something familiar , then immediately removed it upon user feedback about how it conflicts with shell metacharacters: #20258 (comment)

However, note that that PR also introduces 3 new version range operators !, !:, and :! to the spec syntax, which imo would provide a familiar-looking encoding of this specific common type of constraint, as 2.6:!2.7. We could even consider raising a (silencable) DeprecationError whenever we parse any version string containing e.g. .999 or .0.0.1, and optionally fix it without erroring.

Copy link
Copy Markdown
Contributor

@cosmicexplorer cosmicexplorer Dec 7, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume yaml is easier to mechanically transform than python itself, making it an easier prospect than e.g. the equivalent in pants -- see pantsbuild/pants#9434 for a PR i still haven't merged yet.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But also of note, libCST (scripts? util libraries?) would be a great way to implement both automated upgrades to package.py files (if ever needed), as well as "shading" import paths to expose multiple versions of a python package within the same running interpreter.

Comment on lines +57 to +58
module_path = os.path.join(ispec.prefix,
ispec['python'].package.site_packages_dir)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Python libs may be in lib or lib64, this should be made more robust by adding both.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

args += ['--single-version-externally-managed']

# Get all relative paths since we set the root to `prefix`
pure_site_packages_dir = distutils.sysconfig.get_python_lib(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be the distutils of the Python dependency shouldn't it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof yeah you're right. I'll run python.command.

Comment on lines +2439 to +2491
if self._concrete:
return

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already in, see #20196 . Also: local import statements should stay at the top to avoid errors where we use something that is being defined later.

Copy link
Copy Markdown
Member

@alalazo alalazo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few comments.

Comment on lines +46 to +47
env:
COVERAGE: true
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we adding coverage to flake8 tests? Note that on develop we won't hit the same lines of code as far as I can tell. If this is done to test the spack flake8 command, wouldn't a unit test be better?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is done instead to test the bootstrapping of flake8 and mypy wouldn't an explicit test be better?

Comment on lines +71 to +80

executables = ['flake8']

@classmethod
def determine_version(cls, exe):
# flake8 --version output looks like:
# 3.8.2 (...)
output = Executable(exe)('--version', output=str, error=str)
match = re.match(r'^(\S+)', output)
return match.group(1) if match else None
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changeset in this package can be extracted and merged as a separate PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

☝️ This would be an immediate merge if extracted.

Comment on lines +108 to +109
# Easy, we found it externally
# TODO: Add to externals/database?
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this TODO, I am wondering if we should perform installations of Spack dependencies in a separate store - in particular if we proceed reusing as much as possible installed packages with the ASP based concretizer. A use case I have in mind, for instance, is somebody who wants to uninstall everything:

$ spack uninstall -ay

This in my opinion shouldn't uninstall packages that are needed by Spack to run, and shouldn't make a re-build of those dependencies necessary later. In #20068 I used environments to obtain some segregation, but I understand that we may not want to use that mechanism.

Along the same lines, I am not sure we should show clingo or flake8 etc. when a:

$ spack find

command is given, or at least not the ones used for bootstrapping Spack.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the user should be able to uninstall things that Spack build to bootstrap itself, but we should add a dependent on those packages (from Spack) so that they cannot be uninstalled without the -f option. But (for example) Spack will bootstrap different specs depending on the python Spack runs with, and the user may want to remove some of those packages before changing pythons in their default environment (and bootstrapping new versions).



@contextlib.contextmanager
def system_python_context():
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proposal for renaming, if it encounters the taste of other maintainers:

Suggested change
def system_python_context():
def current_python_interpreter_context():

@alalazo
Copy link
Copy Markdown
Member

alalazo commented Dec 3, 2020

I tried this branch on an Ubuntu-18.04 machine and getting the error below:

$ spack -d solve zlib
...
[ long time spent installing ]
...
[+] /home/culpo/PycharmProjects/spack/opt/spack/linux-ubuntu18.04-broadwell/gcc-10.1.0/clingo-spack-5tran6rdhjlgwqc4dxjsmw6xgdoriifr
Traceback (most recent call last):
  File "/home/culpo/PycharmProjects/spack/lib/spack/spack/spack_deps.py", line 89, in make_module_available
    __import__(module)
ModuleNotFoundError: No module named 'clingo'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/culpo/PycharmProjects/spack/bin/spack", line 66, in <module>
    sys.exit(spack.main.main())
  File "/home/culpo/PycharmProjects/spack/lib/spack/spack/main.py", line 762, in main
    return _invoke_command(command, parser, args, unknown)
  File "/home/culpo/PycharmProjects/spack/lib/spack/spack/main.py", line 490, in _invoke_command
    return_val = command(parser, args)
  File "/home/culpo/PycharmProjects/spack/lib/spack/spack/cmd/solve.py", line 100, in solve
    specs, dump=dump, models=models, timers=args.timers, stats=args.stats
  File "/home/culpo/PycharmProjects/spack/lib/spack/spack/solver/asp.py", line 1840, in solve
    driver = PyclingoDriver()
  File "/home/culpo/PycharmProjects/spack/lib/spack/spack/solver/asp.py", line 468, in __init__
    'clingo', spec=clingo_spec, install=True)
  File "/home/culpo/PycharmProjects/spack/lib/spack/spack/spack_deps.py", line 93, in make_module_available
    raise Exception  # TODO: specify
Exception

A strange thing I noticed is that I have many Python interpreters installed on the system, and:

$ spack find -p clingo
==> 1 installed package
-- linux-ubuntu18.04-broadwell / [email protected] ---------------------
clingo@spack  /home/culpo/PycharmProjects/spack/opt/spack/linux-ubuntu18.04-broadwell/gcc-10.1.0/clingo-spack-5tran6rdhjlgwqc4dxjsmw6xgdoriifr
$ ls /home/culpo/PycharmProjects/spack/opt/spack/linux-ubuntu18.04-broadwell/gcc-10.1.0/clingo-spack-5tran6rdhjlgwqc4dxjsmw6xgdoriifr/lib/
cmake  libclingo.so  libclingo.so.4  libclingo.so.4.0  libpyclingo.so  libpyclingo.so.1  libpyclingo.so.1.0  perl5  python2.7  python3.6

Note that I have both a Python2.7 and a Python3.6 folder in the same installation. The Python3.6 folder is empty.

@becker33 becker33 force-pushed the features/detect-and-swap-current-python branch 2 times, most recently from 27cd6af to db7e2f3 Compare December 31, 2020 00:50
flake8 and clingo as test cases

allow python builds with system python as external for macos
use old concretizer to bootstrap new
@becker33 becker33 force-pushed the features/detect-and-swap-current-python branch from db7e2f3 to 05202bc Compare December 31, 2020 00:51
@alalazo
Copy link
Copy Markdown
Member

alalazo commented Jan 20, 2021

@becker33 I tried to rebase this branch locally on my laptop, and bring in the package from #20652 I still have the same error reported in #20207 (comment) meaning I can build clingo from source but Spack is unable to import the module for me. I'll keep investigating what is the issue.

@alalazo
Copy link
Copy Markdown
Member

alalazo commented Jan 20, 2021

Nailed down the issue. In systems with multiple Python versions in the same prefix CMake find_package may detect a Python version that is not the one used in the spec, and thus you'll build a clingo extension for effectively another Python executable. The fix is to provide clingo with hints on which Python executable to use.

Copy link
Copy Markdown
Member

@alalazo alalazo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested manually this PR on top of #20652 and it does the job of building clingo from sources and using it for spack solve and other commands, which is great!

I left a few comments below, but the most concerning one at the moment is that the logic to import clingo conflicts with our unit testing framework. I think it's particularly important to solve this point since when we'll be ready with binary packages we could:

  1. Drop the container we maintain for CI with clingo pre-installed
  2. Just run spack unit-test

and expect Spack to bootstrap clingo and run the tests. Let me know how I can help to push this PR forward.

Comment on lines +21 to +25
python_cls = type(spack.spec.Spec('python').package)
python_prefix = os.path.dirname(os.path.dirname(sys.executable))
externals = python_cls.determine_spec_details(
python_prefix, [os.path.basename(sys.executable)])
external_python = externals[0]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has weird side effects if the code is ever hit with unit tests. The tests based on builtin.mock will fail with:

    @contextlib.contextmanager
    def system_python_context():
        python_cls = type(spack.spec.Spec('python').package)
        python_prefix = os.path.dirname(os.path.dirname(sys.executable))
>       externals = python_cls.determine_spec_details(
            python_prefix, [os.path.basename(sys.executable)])
E       AttributeError: type object 'Python' has no attribute 'determine_spec_details'

since they'll pick the Python from builtin.mock. Other tests are instead installing in the user store:

$ spack find
[ ... ]
-- test-debian6-x86_64 / [email protected] ------------------------------
[email protected]

Comment on lines +46 to +47
env:
COVERAGE: true
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is done instead to test the bootstrapping of flake8 and mypy wouldn't an explicit test be better?

installed_specs = spack.store.db.query(spec, installed=True)

for ispec in installed_specs:
# TODO: make sure run-environment is appropriate
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there anything specific you had in mind for this?

install quickly (when using external python) or are guaranteed by Spack
organization to be in a binary mirror (clingo)."""
# Easy, we found it externally
# TODO: Add to externals/database?
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should add it to the list of externals, as it would be an arbitrary modification of user config for purposes that are implementation details of Spack.

Comment on lines +128 to +129
else:
tty.warn('Exe %s not found in prefix %s' % (exe, ispec.prefix))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a minor simplification:

Suggested change
else:
tty.warn('Exe %s not found in prefix %s' % (exe, ispec.prefix))
tty.warn('Exe %s not found in prefix %s' % (exe, ispec.prefix))

Maybe we can move this message to debug level?

returncode = 0
print_tool_header("flake8")
flake8_cmd = which("flake8", required=True)
flake8_cmd = get_executable('flake8', spec='py-flake8', install=True)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't work for me on a stock ubuntu if I don't install setuptools first on the system:

==> Installing py-setuptools-scm-4.1.2-rd7ecfn5tjaxiwpkdnrxe2znlyleccyl
==> No binary for py-setuptools-scm-4.1.2-rd7ecfn5tjaxiwpkdnrxe2znlyleccyl found: installing from source
==> Using cached archive: /home/culpo/PycharmProjects/spack/var/spack/cache/_source-cache/archive/a8/a8994582e716ec690f33fec70cca0f85bd23ec974e3f783233e4879090a7faa8.tar.gz
==> py-setuptools-scm: Executing phase: 'build'
==> Error: ProcessError: Command exited with status 1:
    '/usr/bin/python3.6' '-s' 'setup.py' '--no-user-cfg' 'build'
See build log for details:
  /tmp/culpo/spack-stage/spack-stage-py-setuptools-scm-4.1.2-rd7ecfn5tjaxiwpkdnrxe2znlyleccyl/spack-build-out.txt

This means installing python3 and python3-setuptools with apt. If I install python3-setuptools I get a different error:

Traceback (most recent call last):
  File "/home/culpo/PycharmProjects/spack/opt/spack/linux-ubuntu18.04-broadwell/gcc-10.1.0/py-flake8-3.8.2-sprazagnhxqodvnn6mzfwtkheligd24v/bin/flake8", line 6, in <module>
    from pkg_resources import load_entry_point
  File "/usr/lib/python3/dist-packages/pkg_resources/__init__.py", line 3088, in <module>
    @_call_aside
  File "/usr/lib/python3/dist-packages/pkg_resources/__init__.py", line 3072, in _call_aside
    f(*args, **kwargs)
  File "/usr/lib/python3/dist-packages/pkg_resources/__init__.py", line 3101, in _initialize_master_working_set
    working_set = WorkingSet._build_master()
  File "/usr/lib/python3/dist-packages/pkg_resources/__init__.py", line 574, in _build_master
    ws.require(__requires__)
  File "/usr/lib/python3/dist-packages/pkg_resources/__init__.py", line 892, in require
    needed = self.resolve(parse_requirements(requirements))
  File "/usr/lib/python3/dist-packages/pkg_resources/__init__.py", line 778, in resolve
    raise DistributionNotFound(req, requirers)
pkg_resources.DistributionNotFound: The 'flake8==3.8.2' distribution was not found and is required by the application
==> Error: Flake8 style checks found errors
==> Error: style: mypy is not available in path, skipping
spack style found errors.

if not clingo:
# TODO: Find a way to vendor the concrete spec
# in a cross-platform way
clingo_spec = spack.spec.Spec('clingo@spack+python')
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once #20652 is in we can modify this to:

Suggested change
clingo_spec = spack.spec.Spec('clingo@spack+python')
generic_target = archspec.cpu.host().family
spec_str = 'clingo-bootstrap@spack+python target={0}'.format(
str(generic_target)
)
clingo_spec = spack.spec.Spec(spec_str)

return rev

def apply_modifications(self):
def apply_modifications(self, env=None):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring should be updated too.


# verify that the code style is correct
spack style
$coverage_run $(which spack) style
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question as above on the opportunity to run coverage on spack style.

Comment on lines +71 to +80

executables = ['flake8']

@classmethod
def determine_version(cls, exe):
# flake8 --version output looks like:
# 3.8.2 (...)
output = Executable(exe)('--version', output=str, error=str)
match = re.match(r'^(\S+)', output)
return match.group(1) if match else None
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

☝️ This would be an immediate merge if extracted.

# We will install for ourselves, using this python if needed
# Concretize the spec
spec.concretize()
spec.package.do_install()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may add something like the following, to try first installing from binary cache:

diff --git a/lib/spack/spack/bootstrap.py b/lib/spack/spack/bootstrap.py
index 9eea069..59b4597 100644
--- a/lib/spack/spack/bootstrap.py
+++ b/lib/spack/spack/bootstrap.py
@@ -78,7 +78,11 @@ def make_module_available(module, spec=None, install=False):
         # We will install for ourselves, using this python if needed
         # Concretize the spec
         spec.concretize()
-    spec.package.do_install()
+    try:
+        spec.package.do_install(cache_only=True)
+    except Exception as e:
+        tty.debug(str(e))
+        spec.package.do_install()
 
     module_path = os.path.join(spec.prefix,
                                spec['python'].package.site_packages_dir)

and if that fails revert to install from sources. Trying the cache only installation first allows us to not install the build dependencies if they are not needed, thus speeding up the bootstrapping process.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit worse than the diff above, since at the moment calling do_install(cache_only=True) raises a SystemExit on failure that requires a BaseException to be caught. We should probably refactor the call such that the exception raised is something else.

@alalazo
Copy link
Copy Markdown
Member

alalazo commented Feb 1, 2021

Just a comment for further discussion, but after extensive test driving of this PR I think having a separate store for software needed by Spack may be beneficial to users:

  • It avoids polluting the user store with software that nobody required to install
  • It avoids bootstrapping more than one time in case we install different environments with their own store defined

@becker33
Copy link
Copy Markdown
Member Author

Closing as superseded by @alalazo 's work in the meantime.

@becker33 becker33 closed this Jul 22, 2021
@haampie haampie deleted the features/detect-and-swap-current-python branch August 2, 2022 09:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants