From 769857b611eee5187548aa567f3d770bfb9e6236 Mon Sep 17 00:00:00 2001 From: Steven Kryskalla Date: Sat, 2 Oct 2021 11:29:39 -0400 Subject: [PATCH] feat: Windows support (#2) ### What I did Added Windows support for the Hardhat network provider. ### How I did it See https://github.com/ApeWorX/ape/issues/91#issuecomment-932458267 for steps on installing Ape on Windows. Once I got that to work, all that was needed was a small amount of OS-specific code to manage the Hardhat process on Windows. ### How to verify it No CI support yet (blocked by https://github.com/ApeWorX/ape/issues/91) but the test suite is passing locally for me: ``` PS C:\Users\orayo\Documents\steve\ape-hardhat> ..\ape-env\Scripts\pytest.exe === test session starts === platform win32 -- Python 3.8.6, pytest-6.2.5, py-1.10.0, pluggy-0.13.1 rootdir: C:\Users\orayo\Documents\steve\ape-hardhat, configfile: pytest.ini plugins: hypothesis-6.23.0, cov-2.12.1, forked-1.3.0, xdist-2.4.0, web3-5.23.1 gw0 [17] / gw1 [17] / gw2 [17] / gw3 [17] ................. [100%] === warnings summary === tests/test_hardhat.py::test_rpc_methods[get_balance-args1-0] c:\users\orayo\documents\steve\ape-env\lib\site-packages\web3\method.py:215: DeprecationWarning: getBalance is deprecated in favor of get_balance warnings.warn( tests/test_hardhat.py::test_rpc_methods[get_nonce-args0-0] c:\users\orayo\documents\steve\ape-env\lib\site-packages\web3\method.py:215: DeprecationWarning: getTransactionCount is deprecated in favor of get_transaction_count warnings.warn( tests/test_hardhat.py::test_rpc_methods[get_code-args2-] c:\users\orayo\documents\steve\ape-env\lib\site-packages\web3\method.py:215: DeprecationWarning: getCode is deprecated in favor of get_code warnings.warn( -- Docs: https://docs.pytest.org/en/stable/warnings.html === 17 passed, 3 warnings in 61.00s (0:01:01) === ``` --- ape_hardhat/__init__.py | 15 ++++++- ape_hardhat/providers.py | 95 ++++++++++++++++++++++++++++++++-------- tests/test_hardhat.py | 4 +- 3 files changed, 92 insertions(+), 22 deletions(-) diff --git a/ape_hardhat/__init__.py b/ape_hardhat/__init__.py index 9afc2bb..f80b6a1 100644 --- a/ape_hardhat/__init__.py +++ b/ape_hardhat/__init__.py @@ -5,7 +5,12 @@ from ape import plugins -from .providers import HardhatNetworkConfig, HardhatProvider +from .providers import ( + HardhatNetworkConfig, + HardhatProvider, + HardhatProviderError, + HardhatSubprocessError, +) @plugins.register(plugins.Config) @@ -16,3 +21,11 @@ def config_class(): @plugins.register(plugins.ProviderPlugin) def providers(): yield "ethereum", "development", HardhatProvider + + +__all__ = [ + "HardhatNetworkConfig", + "HardhatProvider", + "HardhatProviderError", + "HardhatSubprocessError", +] diff --git a/ape_hardhat/providers.py b/ape_hardhat/providers.py index 4c58c9f..745947e 100644 --- a/ape_hardhat/providers.py +++ b/ape_hardhat/providers.py @@ -6,6 +6,7 @@ import ctypes import platform import random +import shutil import signal import sys import time @@ -32,6 +33,7 @@ """ HARDHAT_START_NETWORK_RETRIES = [0.1, 0.2, 0.3, 0.5, 1.0] # seconds between network retries HARDHAT_START_PROCESS_ATTEMPTS = 3 # number of attempts to start subprocess before giving up +PROCESS_WAIT_TIMEOUT = 15 # seconds to wait for process to terminate def _signal_handler(signum, frame): @@ -40,17 +42,66 @@ def _signal_handler(signum, frame): sys.exit(143 if signum == signal.SIGTERM else 130) -def _set_death_signal(): - """Automatically sends SIGTERM to child subprocesses when parent process dies.""" +def _linux_set_death_signal(): + """ + Automatically sends SIGTERM to child subprocesses when parent process + dies (only usable on Linux). + """ + # from: https://stackoverflow.com/a/43152455/75956 + # the first argument, 1, is the flag for PR_SET_PDEATHSIG + # the second argument is what signal to send to child subprocesses + libc = ctypes.CDLL("libc.so.6") + return libc.prctl(1, signal.SIGTERM) + + +def _get_preexec_fn(): if platform.uname().system == "Linux": - # from: https://stackoverflow.com/a/43152455/75956 - # the first argument "1" is PR_SET_PDEATHSIG - # the second argument is what signal to send the child - libc = ctypes.CDLL("libc.so.6") - return libc.prctl(1, signal.SIGTERM) + return _linux_set_death_signal + else: + return None + + +def _windows_taskkill(pid: int) -> None: + """ + Kills the given process and all child processes using taskkill.exe. Used + for subprocesses started up on Windows which run in a cmd.exe wrapper that + doesn't propagate signals by default (leaving orphaned processes). + """ + taskkill_bin = shutil.which("taskkill") + if not taskkill_bin: + raise HardhatSubprocessError("Could not find taskkill.exe executable.") + proc = Popen( + [ + taskkill_bin, + "/F", # forcefully terminate + "/T", # terminate child processes + "/PID", + str(pid), + ], + stderr=PIPE, + stdout=PIPE, + stdin=PIPE, + ) + proc.wait(timeout=PROCESS_WAIT_TIMEOUT) + + +def _kill_process(proc): + """Helper function for killing a process and its child subprocesses.""" + if platform.uname().system == "Windows": + _windows_taskkill(proc.pid) + proc.kill() + proc.wait(timeout=PROCESS_WAIT_TIMEOUT) + + +class HardhatProviderError(ProviderError): + """An error related to the Hardhat network provider plugin.""" + + pass -class HardhatSubprocessError(ProviderError): +class HardhatSubprocessError(HardhatProviderError): + """An error related to launching subprocesses to run Hardhat.""" + pass @@ -70,17 +121,22 @@ def __post_init__(self): ) self.port = None self.process = None + self.npx_bin = shutil.which("npx") hardhat_config_file = self.network.config_manager.PROJECT_FOLDER / "hardhat.config.js" if not hardhat_config_file.is_file(): hardhat_config_file.write_text(HARDHAT_CONFIG) - if call(["npx", "--version"], stderr=PIPE, stdout=PIPE, stdin=PIPE) != 0: + if not self.npx_bin: + raise HardhatSubprocessError( + "Could not locate NPM executable. See ape-hardhat README for install steps." + ) + if call([self.npx_bin, "--version"], stderr=PIPE, stdout=PIPE, stdin=PIPE) != 0: raise HardhatSubprocessError( - "Missing npx binary. See ape-hardhat README for install steps." + "NPM executable returned error code. See ape-hardhat README for install steps." ) - if call(["npx", "hardhat", "--version"], stderr=PIPE, stdout=PIPE, stdin=PIPE) != 0: + if call([self.npx_bin, "hardhat", "--version"], stderr=PIPE, stdout=PIPE, stdin=PIPE) != 0: raise HardhatSubprocessError( "Missing hardhat NPM package. See ape-hardhat README for install steps." ) @@ -95,8 +151,7 @@ def __post_init__(self): def _start_process(self): """Start the hardhat process and wait for it to respond over the network.""" - # handle configs - cmd = ["npx", "hardhat", "node"] + cmd = [self.npx_bin, "hardhat", "node"] # pick a random port if one isn't configured self.port = self.config.port @@ -105,7 +160,7 @@ def _start_process(self): cmd.extend(["--port", str(self.port)]) # TODO: Add configs to send stdout to logger / redirect to a file in plugin data dir? - process = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, preexec_fn=_set_death_signal) + process = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, preexec_fn=_get_preexec_fn()) connected = False for retry_time in self.config.network_retries: time.sleep(retry_time) @@ -130,12 +185,12 @@ def _start_process(self): ) self.process = process - def _verify_connection(self): + def _verify_connection(self) -> bool: """Make a network call for chain_id and verify the result.""" try: chain_id = self.chain_id if chain_id != HARDHAT_CHAIN_ID: - raise AssertionError(f"Unexpected chain ID: {chain_id}") + raise HardhatProviderError(f"Unexpected chain ID: {chain_id}") return True except Exception as exc: print("Hardhat connection failed:", exc) @@ -145,7 +200,9 @@ def connect(self): """Start the hardhat process and verify it's up and accepting connections.""" if self.process: - raise RuntimeError("Cannot connect twice. Call disconnect before connecting again.") + raise HardhatProviderError( + "Cannot connect twice. Call disconnect before connecting again." + ) if self.config.port: # if a port is configured, only make one start up attempt @@ -177,7 +234,7 @@ def uri(self) -> str: # number, so let's build the URI using that port number return f"http://localhost:{self.port}" else: - raise RuntimeError("Can't build URI before `connect` is called.") + raise HardhatProviderError("Can't build URI before `connect` is called.") @property # type: ignore def _web3(self): @@ -197,7 +254,7 @@ def _web3(self, value): def disconnect(self): super().disconnect() if self.process: - self.process.kill() + _kill_process(self.process) self.process = None self.port = None diff --git a/tests/test_hardhat.py b/tests/test_hardhat.py index e7a7705..e2e5c88 100644 --- a/tests/test_hardhat.py +++ b/tests/test_hardhat.py @@ -5,7 +5,7 @@ from conftest import get_network_config from hexbytes import HexBytes -from ape_hardhat import HardhatProvider +from ape_hardhat import HardhatProvider, HardhatProviderError TEST_WALLET_ADDRESS = "0xD9b7fdb3FC0A0Aa3A507dCf0976bc23D49a9C7A3" TEST_CUSTOM_PORT = 8555 # vs. Hardhat's default of 8545 @@ -139,5 +139,5 @@ def test_unlock_account(hardhat_provider): def test_double_connect(hardhat_provider): # connect has already been called once as part of the fixture, so connecting again should fail - with pytest.raises(RuntimeError): + with pytest.raises(HardhatProviderError): hardhat_provider.connect()