Skip to content

Commit bec823a

Browse files
committed
fix(io): use atomic writes for .param, JSON, and settings files
All file-writing operations used a simple open("w") → write() pattern. If the application crashes or power is lost mid-write, the target file is left truncated or empty. This is especially dangerous for .param files (incomplete parameter sets uploaded to a flight controller could cause unpredictable behavior) and vehicle_components.json (corrupted components file breaks all derived parameter computations on startup). Add a safe_write() utility that writes to a temporary file in the same directory, then atomically replaces the target via os.replace(). This ensures the file is either fully written or untouched — never truncated. Apply safe_write to the four most critical persistence paths: - ParDict.export_to_param() — .param files - FilesystemJSONWithSchema.save_json_data() — JSON data files - ProgramSettings._set_settings_from_dict() — settings.json - VehicleComponents.save_component_templates_to_file() — templates Closes #1428 Signed-off-by: Yash Goel <yashhzd@users.noreply.github.com>
1 parent 2308180 commit bec823a

File tree

5 files changed

+56
-11
lines changed

5 files changed

+56
-11
lines changed

ardupilot_methodic_configurator/backend_filesystem_json_with_schema.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from jsonschema import ValidationError, validate, validators
2323

2424
from ardupilot_methodic_configurator import _
25+
from ardupilot_methodic_configurator.common_safe_file_io import safe_write
2526

2627

2728
class FilesystemJSONWithSchema:
@@ -124,11 +125,11 @@ def save_json_data(self, data: dict, data_dir: str) -> tuple[bool, str]: # noqa
124125

125126
filepath = os_path.join(data_dir, self.json_filename)
126127
try:
127-
with open(filepath, "w", encoding="utf-8", newline="\n") as file:
128-
json_str = json_dumps(data, indent=4)
129-
# Strip the last newline to avoid double newlines
130-
# This is to ensure compatibility with pre-commit's end-of-file-fixer
131-
file.write(json_str.rstrip("\n") + "\n")
128+
json_str = json_dumps(data, indent=4)
129+
# Strip the last newline to avoid double newlines
130+
# This is to ensure compatibility with pre-commit's end-of-file-fixer
131+
content = json_str.rstrip("\n") + "\n"
132+
safe_write(filepath, lambda f: f.write(content))
132133
except FileNotFoundError:
133134
msg = _("Directory '{}' not found").format(data_dir)
134135
logging_error(msg)

ardupilot_methodic_configurator/backend_filesystem_program_settings.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from platformdirs import site_config_dir, user_config_dir
3535

3636
from ardupilot_methodic_configurator import _
37+
from ardupilot_methodic_configurator.common_safe_file_io import safe_write
3738
from ardupilot_methodic_configurator.data_model_recent_items_history_list import RecentItemsHistoryList
3839

3940
# Platform detection constant to avoid repeated system calls
@@ -320,9 +321,7 @@ def _load_settings_from_file(settings_path: str) -> dict[str, Any]:
320321
@staticmethod
321322
def _set_settings_from_dict(settings: dict) -> None:
322323
settings_path = os_path.join(ProgramSettings._user_config_dir(), "settings.json")
323-
324-
with open(settings_path, "w", encoding="utf-8", newline="\n") as settings_file:
325-
json_dump(settings, settings_file, indent=4)
324+
safe_write(settings_path, lambda f: json_dump(settings, f, indent=4))
326325

327326
@staticmethod
328327
def _is_template_directory(vehicle_dir: str) -> bool:

ardupilot_methodic_configurator/backend_filesystem_vehicle_components.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from ardupilot_methodic_configurator import _
2828
from ardupilot_methodic_configurator.backend_filesystem_json_with_schema import FilesystemJSONWithSchema
2929
from ardupilot_methodic_configurator.backend_filesystem_program_settings import ProgramSettings
30+
from ardupilot_methodic_configurator.common_safe_file_io import safe_write
3031
from ardupilot_methodic_configurator.data_model_template_overview import TemplateOverview
3132
from ardupilot_methodic_configurator.data_model_vehicle_components_json_schema import VehicleComponentsJsonSchema
3233

@@ -264,8 +265,7 @@ def save_component_templates_to_file(self, templates_to_save: dict[str, list[dic
264265
templates = {**existing_templates, **templates_to_save} if existing_templates else templates_to_save
265266

266267
try:
267-
with open(filepath, "w", encoding="utf-8", newline="\n") as file: # use Linux line endings even on Windows
268-
json_dump(templates, file, indent=4)
268+
safe_write(filepath, lambda f: json_dump(templates, f, indent=4))
269269
return False, normalized_filepath # Success, return the filepath
270270
except FileNotFoundError:
271271
msg = _("File not found when writing to '{}': {}").format(normalized_filepath, _("Path not found"))
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""
2+
Crash-safe file writing utilities.
3+
4+
Provides atomic write operations that prevent data corruption from
5+
crashes, power loss, or OS-level interruptions during file writes.
6+
7+
SPDX-FileCopyrightText: 2024-2026 Amilcar do Carmo Lucas <amilcar.lucas@iav.de>
8+
9+
SPDX-License-Identifier: GPL-3.0-or-later
10+
"""
11+
12+
import os
13+
import tempfile
14+
from typing import IO, Callable
15+
16+
17+
def safe_write(filepath: str, write_func: Callable[[IO], None]) -> None:
18+
"""
19+
Write to a temporary file, then atomically replace the target.
20+
21+
This ensures the target file is either fully written or untouched —
22+
never truncated or empty due to a crash mid-write.
23+
24+
Args:
25+
filepath: The target file path to write to.
26+
write_func: A callable that receives an open file handle and writes content to it.
27+
28+
"""
29+
dir_name = os.path.dirname(filepath) or "."
30+
fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
31+
try:
32+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as tmp_file:
33+
write_func(tmp_file)
34+
os.replace(tmp_path, filepath) # atomic on POSIX, near-atomic on Windows
35+
except BaseException:
36+
with open(os.devnull, "w") as _:
37+
try:
38+
os.unlink(tmp_path)
39+
except OSError:
40+
pass
41+
raise

ardupilot_methodic_configurator/data_model_par_dict.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from typing import Callable, Optional, Union
2020

2121
from ardupilot_methodic_configurator import _
22+
from ardupilot_methodic_configurator.common_safe_file_io import safe_write
2223

2324
# ArduPilot parameter names start with a capital letter and can have capital letters, numbers and _
2425
PARAM_NAME_REGEX = r"^[A-Z][A-Z_0-9]*$"
@@ -294,11 +295,14 @@ def export_to_param(
294295
295296
"""
296297
formatted_params = self._format_params(file_format)
297-
with open(filename_out, "w", encoding="utf-8", newline="\n") as output_file: # use Linux line endings even on Windows
298+
299+
def _write(output_file): # type: ignore[no-untyped-def]
298300
if content_header:
299301
output_file.write("\n".join(content_header) + "\n")
300302
output_file.writelines(line + "\n" for line in formatted_params)
301303

304+
safe_write(filename_out, _write)
305+
302306
@staticmethod
303307
def print_out(formatted_params: list[str], name: str) -> None:
304308
"""

0 commit comments

Comments
 (0)