Skip to content

Commit

Permalink
De-run_forever()-ization
Browse files Browse the repository at this point in the history
* Use asyncio.run (or our own implementation on Python <3.7)
* hass.start is only used by tests
* setup_and_run_hass() is now async
* Add "main" async hass.run method
* move SIGINT handling to helpers/signal.py
  * add flag to .run to disable hass's signal handlers
* Teach async_start and async_stop to not step on each other
  (more than necessary)
  • Loading branch information
smurfix committed Sep 14, 2018
1 parent 67b5b5b commit 5023ed1
Show file tree
Hide file tree
Showing 9 changed files with 92 additions and 34 deletions.
32 changes: 13 additions & 19 deletions homeassistant/__main__.py
Expand Up @@ -21,8 +21,6 @@

def attempt_use_uvloop() -> None:
"""Attempt to use uvloop."""
import asyncio

try:
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
Expand Down Expand Up @@ -239,10 +237,10 @@ def cmdline() -> List[str]:
return [arg for arg in sys.argv if arg != '--daemon']


def setup_and_run_hass(config_dir: str,
args: argparse.Namespace) -> int:
async def setup_and_run_hass(config_dir: str,
args: argparse.Namespace) -> int:
"""Set up HASS and run."""
from homeassistant import bootstrap
from homeassistant import bootstrap, core

# Run a simple daemon runner process on Windows to handle restarts
if os.name == 'nt' and '--runner' not in sys.argv:
Expand All @@ -255,26 +253,25 @@ def setup_and_run_hass(config_dir: str,
if exc.returncode != RESTART_EXIT_CODE:
sys.exit(exc.returncode)

hass = core.HomeAssistant()

if args.demo_mode:
config = {
'frontend': {},
'demo': {}
} # type: Dict[str, Any]
hass = bootstrap.from_config_dict(
config, config_dir=config_dir, verbose=args.verbose,
bootstrap.async_from_config_dict(
config, hass, config_dir=config_dir, verbose=args.verbose,
skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days,
log_file=args.log_file, log_no_color=args.log_no_color)
else:
config_file = ensure_config_file(config_dir)
print('Config directory:', config_dir)
hass = bootstrap.from_config_file(
config_file, verbose=args.verbose, skip_pip=args.skip_pip,
await bootstrap.async_from_config_file(
config_file, hass, verbose=args.verbose, skip_pip=args.skip_pip,
log_rotate_days=args.log_rotate_days, log_file=args.log_file,
log_no_color=args.log_no_color)

if hass is None:
return -1

if args.open_ui:
# Imported here to avoid importing asyncio before monkey patch
from homeassistant.util.async_ import run_callback_threadsafe
Expand All @@ -285,13 +282,9 @@ def open_browser(_: Any) -> None:
import webbrowser
webbrowser.open(hass.config.api.base_url) # type: ignore

run_callback_threadsafe(
hass.loop,
hass.bus.async_listen_once,
EVENT_HOMEASSISTANT_START, open_browser
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, open_browser)

return hass.start()
return await hass.run()


def try_to_restart() -> None:
Expand Down Expand Up @@ -365,7 +358,8 @@ def main() -> int:
if args.pid_file:
write_pid(args.pid_file)

exit_code = setup_and_run_hass(config_dir, args)
from homeassistant.util import asyncio_run
exit_code = asyncio_run(setup_and_run_hass(config_dir, args))
if exit_code == RESTART_EXIT_CODE and not args.runner:
try_to_restart()

Expand Down
2 changes: 0 additions & 2 deletions homeassistant/bootstrap.py
Expand Up @@ -18,7 +18,6 @@
from homeassistant.util.package import async_get_user_site, is_virtual_env
from homeassistant.util.yaml import clear_secret_cache
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.signal import async_register_signal_handling

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -159,7 +158,6 @@ async def async_from_config_dict(config: Dict[str, Any],
stop = time()
_LOGGER.info("Home Assistant initialized in %.2fs", stop-start)

async_register_signal_handling(hass)
return hass


Expand Down
58 changes: 51 additions & 7 deletions homeassistant/core.py
Expand Up @@ -154,30 +154,52 @@ def __init__(
self.state = CoreState.not_running
self.exit_code = 0 # type: int
self.config_entries = None # type: Optional[ConfigEntries]
# If not None, use to signal end-of-loop
self._stopped = None # type: Optional[asyncio.Event]

@property
def is_running(self) -> bool:
"""Return if Home Assistant is running."""
return self.state in (CoreState.starting, CoreState.running)

def start(self) -> int:
"""Start home assistant."""
"""Start home assistant.
Note: This function is only used for testing.
For regular use, use "await hass.run()".
"""
# Register the async start
fire_coroutine_threadsafe(self.async_start(), self.loop)

# Run forever and catch keyboard interrupt
# Run forever
try:
# Block until stopped
_LOGGER.info("Starting Home Assistant core loop")
self.loop.run_forever()
except KeyboardInterrupt:
self.loop.call_soon_threadsafe(
self.loop.create_task, self.async_stop())
self.loop.run_forever()
finally:
self.loop.close()
return self.exit_code

async def run(self, *, attach_signals: bool = True):
"""Home Assistant main entry point.
Start Home Assistant and block until stopped.
This method is a coroutine.
"""
if self.state != CoreState.not_running:
raise RuntimeError("HASS is already running")

# _async_stop will set this instead of stopping the loop
self._stopped = asyncio.Event()

await self.async_start()
if attach_signals:
from homeassistant.helpers.signal import async_register_signal_handling
async_register_signal_handling(self)

await self._stopped.wait()

async def async_start(self) -> None:
"""Finalize startup from inside the event loop.
Expand All @@ -203,6 +225,13 @@ async def async_start(self) -> None:

# Allow automations to set up the start triggers before changing state
await asyncio.sleep(0)

if self.state != CoreState.starting:
_LOGGER.warning(
'Home Assistant startup has been interrupted. '
'Its state may be inconsistent.')
return

self.state = CoreState.running
_async_create_timer(self)

Expand Down Expand Up @@ -321,13 +350,24 @@ async def async_block_till_done(self) -> None:

def stop(self) -> None:
"""Stop Home Assistant and shuts down all threads."""
if self.state == CoreState.not_running: # just ignore
return
fire_coroutine_threadsafe(self.async_stop(), self.loop)

async def async_stop(self, exit_code: int = 0) -> None:
"""Stop Home Assistant and shuts down all threads.
This method is a coroutine.
"""
if self.state == CoreState.not_running: # just ignore
return
if self.state == CoreState.stopping:
_LOGGER.info("async_stop called twice: ignored")
return
if self.state == CoreState.starting:
# This may not work
_LOGGER.warn("async_stop called before startup is complete")

# stage 1
self.state = CoreState.stopping
self.async_track_tasks()
Expand All @@ -341,7 +381,11 @@ async def async_stop(self, exit_code: int = 0) -> None:
self.executor.shutdown()

self.exit_code = exit_code
self.loop.stop()

if self._stopped is not None:
self._stopped.set()
else:
self.loop.stop()


@attr.s(slots=True, frozen=True)
Expand Down
10 changes: 9 additions & 1 deletion homeassistant/helpers/signal.py
Expand Up @@ -17,7 +17,9 @@ def async_register_signal_handling(hass: HomeAssistant) -> None:
if sys.platform != 'win32':
@callback
def async_signal_handle(exit_code):
"""Wrap signal handling."""
"""Wrap signal handling: queue shutdown code, re-instate default handler."""
hass.loop.remove_signal_handler(signal.SIGTERM)
hass.loop.remove_signal_handler(signal.SIGINT)
hass.async_create_task(hass.async_stop(exit_code))

try:
Expand All @@ -26,6 +28,12 @@ def async_signal_handle(exit_code):
except ValueError:
_LOGGER.warning("Could not bind to SIGTERM")

try:
hass.loop.add_signal_handler(
signal.SIGINT, async_signal_handle, 0)
except ValueError:
_LOGGER.warning("Could not bind to SIGINT")

try:
hass.loop.add_signal_handler(
signal.SIGHUP, async_signal_handle, RESTART_EXIT_CODE)
Expand Down
9 changes: 9 additions & 0 deletions homeassistant/util/__init__.py
Expand Up @@ -30,6 +30,15 @@
}


try:
asyncio_run = asyncio.run
except AttributeError:
def asyncio_run(main, *, debug=False):
loop = asyncio.new_event_loop()
loop.set_debug(debug)
return loop.run(main)


def sanitize_filename(filename: str) -> str:
r"""Sanitize a filename by removing .. / and \\."""
return RE_SANITIZE_FILENAME.sub("", filename)
Expand Down
1 change: 1 addition & 0 deletions requirements_test_all.txt
Expand Up @@ -3,6 +3,7 @@
# make new things fail. Manually update these pins when pulling in a
# new version
asynctest==0.12.2
attrs==18.2.0
coveralls==1.2.0
flake8-docstrings==1.3.0
flake8==3.5
Expand Down
11 changes: 9 additions & 2 deletions tests/components/sensor/test_dsmr.py
Expand Up @@ -182,10 +182,14 @@ def test_reconnect(hass, monkeypatch, mock_connection_factory):

# mock waiting coroutine while connection lasts
closed = asyncio.Event(loop=hass.loop)
# Handshake so that `hass.async_block_till_done()` doesn't cycle forever
closed2 = asyncio.Event(loop=hass.loop)

@asyncio.coroutine
def wait_closed():
yield from closed.wait()
closed2.set()
closed.clear()
protocol.wait_closed = wait_closed

yield from async_setup_component(hass, 'sensor', {'sensor': config})
Expand All @@ -195,8 +199,11 @@ def wait_closed():
# indicate disconnect, release wait lock and allow reconnect to happen
closed.set()
# wait for lock set to resolve
yield from hass.async_block_till_done()
# wait for sleep to resolve
yield from closed2.wait()
closed2.clear()
assert not closed.is_set()

closed.set()
yield from hass.async_block_till_done()

assert connection_factory.call_count >= 2, \
Expand Down
1 change: 0 additions & 1 deletion tests/helpers/test_discovery.py
Expand Up @@ -154,7 +154,6 @@ def setup_platform(hass, config, add_entities_callback,
assert 'test_component' in self.hass.config.components
assert 'switch' in self.hass.config.components

@patch('homeassistant.bootstrap.async_register_signal_handling')
def test_1st_discovers_2nd_component(self, mock_signal):
"""Test that we don't break if one component discovers the other.
Expand Down
2 changes: 0 additions & 2 deletions tests/test_bootstrap.py
Expand Up @@ -22,7 +22,6 @@
'homeassistant.bootstrap.conf_util.process_ha_config_upgrade', Mock())
@patch('homeassistant.util.location.detect_location_info',
Mock(return_value=None))
@patch('homeassistant.bootstrap.async_register_signal_handling', Mock())
@patch('os.path.isfile', Mock(return_value=True))
@patch('os.access', Mock(return_value=True))
@patch('homeassistant.bootstrap.async_enable_logging',
Expand All @@ -41,7 +40,6 @@ def test_from_config_file(hass):


@patch('homeassistant.bootstrap.async_enable_logging', Mock())
@patch('homeassistant.bootstrap.async_register_signal_handling', Mock())
@asyncio.coroutine
def test_home_assistant_core_config_validation(hass):
"""Test if we pass in wrong information for HA conf."""
Expand Down

0 comments on commit 5023ed1

Please sign in to comment.