Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 32 additions & 26 deletions homeassistant/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,36 +187,42 @@ def main() -> int:

from . import config, runner # noqa: PLC0415

safe_mode = config.safe_mode_enabled(config_dir)

runtime_conf = runner.RuntimeConfig(
config_dir=config_dir,
verbose=args.verbose,
log_rotate_days=args.log_rotate_days,
log_file=args.log_file,
log_no_color=args.log_no_color,
skip_pip=args.skip_pip,
skip_pip_packages=args.skip_pip_packages,
recovery_mode=args.recovery_mode,
debug=args.debug,
open_ui=args.open_ui,
safe_mode=safe_mode,
)
# Ensure only one instance runs per config directory
with runner.ensure_single_execution(config_dir) as single_execution_lock:
# Check if another instance is already running
if single_execution_lock.exit_code is not None:
return single_execution_lock.exit_code

safe_mode = config.safe_mode_enabled(config_dir)

runtime_conf = runner.RuntimeConfig(
config_dir=config_dir,
verbose=args.verbose,
log_rotate_days=args.log_rotate_days,
log_file=args.log_file,
log_no_color=args.log_no_color,
skip_pip=args.skip_pip,
skip_pip_packages=args.skip_pip_packages,
recovery_mode=args.recovery_mode,
debug=args.debug,
open_ui=args.open_ui,
safe_mode=safe_mode,
)

fault_file_name = os.path.join(config_dir, FAULT_LOG_FILENAME)
with open(fault_file_name, mode="a", encoding="utf8") as fault_file:
faulthandler.enable(fault_file)
exit_code = runner.run(runtime_conf)
faulthandler.disable()
fault_file_name = os.path.join(config_dir, FAULT_LOG_FILENAME)
with open(fault_file_name, mode="a", encoding="utf8") as fault_file:
faulthandler.enable(fault_file)
exit_code = runner.run(runtime_conf)
faulthandler.disable()

# It's possible for the fault file to disappear, so suppress obvious errors
with suppress(FileNotFoundError):
if os.path.getsize(fault_file_name) == 0:
os.remove(fault_file_name)
# It's possible for the fault file to disappear, so suppress obvious errors
with suppress(FileNotFoundError):
if os.path.getsize(fault_file_name) == 0:
os.remove(fault_file_name)

check_threads()
check_threads()

return exit_code
return exit_code


if __name__ == "__main__":
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/conversation/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.8.29"]
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.3"]
}
4 changes: 2 additions & 2 deletions homeassistant/components/fritz/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,8 @@
"description": "Sets a new password for the guest Wi-Fi. The password must be between 8 and 63 characters long. If no additional parameter is set, the password will be auto-generated with a length of 12 characters.",
"fields": {
"device_id": {
"name": "Fritz!Box Device",
"description": "Select the Fritz!Box to configure."
"name": "FRITZ!Box Device",
"description": "Select the FRITZ!Box to configure."
},
"password": {
"name": "[%key:common::config_flow::data::password%]",
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/package_constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ hass-nabucasa==1.1.0
hassil==3.2.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250903.2
home-assistant-intents==2025.8.29
home-assistant-intents==2025.9.3
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6
Expand Down
115 changes: 115 additions & 0 deletions homeassistant/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,28 @@
from __future__ import annotations

import asyncio
from collections.abc import Generator
from contextlib import contextmanager
import dataclasses
from datetime import datetime
import fcntl
from io import TextIOWrapper
import json
import logging
import os
from pathlib import Path
import subprocess
import sys
import threading
import time
from time import monotonic
import traceback
from typing import Any

import packaging.tags

from . import bootstrap
from .const import __version__
from .core import callback
from .helpers.frame import warn_use
from .util.executor import InterruptibleThreadPoolExecutor
Expand All @@ -33,9 +44,113 @@
MAX_EXECUTOR_WORKERS = 64
TASK_CANCELATION_TIMEOUT = 5

# Lock file configuration
LOCK_FILE_NAME = ".ha_run.lock"
LOCK_FILE_VERSION = 1 # Increment if format changes

_LOGGER = logging.getLogger(__name__)


@dataclasses.dataclass
class SingleExecutionLock:
"""Context object for single execution lock."""

exit_code: int | None = None


def _write_lock_info(lock_file: TextIOWrapper) -> None:
"""Write current instance information to the lock file.

Args:
lock_file: The open lock file handle.
"""
lock_file.seek(0)
lock_file.truncate()

instance_info = {
"pid": os.getpid(),
"version": LOCK_FILE_VERSION,
"ha_version": __version__,
"start_ts": time.time(),
}
json.dump(instance_info, lock_file)
lock_file.flush()


def _report_existing_instance(lock_file_path: Path, config_dir: str) -> None:
"""Report that another instance is already running.

Attempts to read the lock file to provide details about the running instance.
"""
error_msg: list[str] = []
error_msg.append("Error: Another Home Assistant instance is already running!")

# Try to read information about the existing instance
try:
with open(lock_file_path, encoding="utf-8") as f:
if content := f.read().strip():
existing_info = json.loads(content)
start_dt = datetime.fromtimestamp(existing_info["start_ts"])
# Format with timezone abbreviation if available, otherwise add local time indicator
if tz_abbr := start_dt.strftime("%Z"):
start_time = start_dt.strftime(f"%Y-%m-%d %H:%M:%S {tz_abbr}")
else:
start_time = (
start_dt.strftime("%Y-%m-%d %H:%M:%S") + " (local time)"
)

error_msg.append(f" PID: {existing_info['pid']}")
error_msg.append(f" Version: {existing_info['ha_version']}")
error_msg.append(f" Started: {start_time}")
else:
error_msg.append(" Unable to read lock file details.")
except (json.JSONDecodeError, OSError) as ex:
error_msg.append(f" Unable to read lock file details: {ex}")

error_msg.append(f" Config directory: {config_dir}")
error_msg.append("")
error_msg.append("Please stop the existing instance before starting a new one.")

for line in error_msg:
print(line, file=sys.stderr) # noqa: T201


@contextmanager
def ensure_single_execution(config_dir: str) -> Generator[SingleExecutionLock]:
"""Ensure only one Home Assistant instance runs per config directory.

Uses file locking to prevent multiple instances from running with the
same configuration directory, which can cause data corruption.

Returns a context object with exit_code attribute that will be set
if another instance is already running.
"""
lock_file_path = Path(config_dir) / LOCK_FILE_NAME
lock_context = SingleExecutionLock()

# Open with 'a+' mode to avoid truncating existing content
# This allows us to read existing content if lock fails
with open(lock_file_path, "a+", encoding="utf-8") as lock_file:
# Try to acquire an exclusive, non-blocking lock
# This will raise BlockingIOError if lock is already held
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
# Another instance is already running
_report_existing_instance(lock_file_path, config_dir)
lock_context.exit_code = 1
yield lock_context
return # Exit early since we couldn't get the lock

# If we got the lock (no exception), write our instance info
_write_lock_info(lock_file)

# Yield the context - lock will be released when the with statement closes the file
# IMPORTANT: We don't unlink the file to avoid races where multiple processes
# could create different lock files
yield lock_context


@dataclasses.dataclass(slots=True)
class RuntimeConfig:
"""Class to hold the information for running Home Assistant."""
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion script/hassfest/docker/Dockerfile

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tests/components/esphome/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2529,7 +2529,7 @@ async def test_discovery_dhcp_no_probe_same_host_port_none(
service_info = DhcpServiceInfo(
ip="192.168.43.183",
hostname="test8266",
macaddress="11:22:33:44:55:aa", # Same MAC as configured
macaddress="1122334455aa", # Same MAC as configured
)

result = await hass.config_entries.flow.async_init(
Expand Down
12 changes: 10 additions & 2 deletions tests/test_data_entry_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1071,13 +1071,19 @@ async def async_step_init(self, user_input=None):


@pytest.mark.parametrize(
"menu_options",
[["target1", "target2"], {"target1": "Target 1", "target2": "Target 2"}],
("menu_options", "sort", "expect_sort"),
[
(["target1", "target2"], None, None),
({"target1": "Target 1", "target2": "Target 2"}, False, None),
(["target2", "target1"], True, True),
],
)
async def test_show_menu(
hass: HomeAssistant,
manager: MockFlowManager,
menu_options: list[str] | dict[str, str],
sort: bool | None,
expect_sort: bool | None,
) -> None:
"""Test show menu."""
manager.hass = hass
Expand All @@ -1093,6 +1099,7 @@ async def async_step_init(self, user_input=None):
step_id="init",
menu_options=menu_options,
description_placeholders={"name": "Paulus"},
sort=sort,
)

async def async_step_target1(self, user_input=None):
Expand All @@ -1105,6 +1112,7 @@ async def async_step_target2(self, user_input=None):
assert result["type"] == data_entry_flow.FlowResultType.MENU
assert result["menu_options"] == menu_options
assert result["description_placeholders"] == {"name": "Paulus"}
assert result.get("sort") == expect_sort
assert len(manager.async_progress()) == 1
assert len(manager.async_progress_by_handler("test")) == 1
assert manager.async_get(result["flow_id"])["handler"] == "test"
Expand Down
Loading
Loading