Skip to content

Non-atomic write of ~/.aea/cli_config.yaml can permanently break every aea/autonomy command #930

Description

@OjusWiZard

Describe the bug

_init_cli_config writes ~/.aea/cli_config.yaml by truncating the real file in place:

https://github.com/valory-xyz/open-aea/blob/v2.2.9/aea/cli/utils/config.py#L121-L128

def _init_cli_config(config: Optional[Dict] = None) -> None:
    """Create cli config folder and file."""
    config = config or DEFAULT_CLI_CONFIG
    conf_dir = os.path.dirname(CLI_CONFIG_PATH)
    if not os.path.exists(conf_dir):
        os.makedirs(conf_dir)
    with open_file(CLI_CONFIG_PATH, "w+") as f:
        yaml.dump(config, f, default_flow_style=False)

open(..., "w+") empties the file the moment it is called, and it stays empty until yaml.dump finishes. Anything that stops the process in that window — Ctrl-C, SIGTERM, a full disk, an OOM kill — leaves a zero-byte cli_config.yaml behind.

That is not a recoverable state. get_or_create_cli_config only catches FileNotFoundError, so an empty file is loaded as None, fails validate_cli_config, and raises. The read happens in registry_flag at the click-group level, so it runs before any command body does. Every aea and autonomy command then crashes with an unhandled traceback, including aea --version, and including aea init --reset — the command you would reach for to repair it. The only way out is knowing to rm ~/.aea/cli_config.yaml by hand.

$ aea --version
Traceback (most recent call last):
  ...
  File ".../aea/cli/utils/click_utils.py", line 303, in registry_flag
    get_or_create_cli_config().get("registry_config", {}).get("default")
  File ".../aea/cli/utils/config.py", line 160, in get_or_create_cli_config
    validate_cli_config(config)
  File ".../aea/cli/utils/config.py", line 118, in validate_cli_config
    validator.validate(config)
aea.helpers.json_schema.ValidationError: 'author' is a required property

The same window is also a read/write race: a second aea process sharing the same HOME can read the file while a first one is rewriting it, and gets the same ValidationError.

To Reproduce

The permanent-corruption case needs no concurrency and no interrupt — an empty file is the whole reproduction:

export HOME=$(mktemp -d)
mkdir -p "$HOME/.aea"
aea init --reset --author valory --local   # works, writes a valid config
: > "$HOME/.aea/cli_config.yaml"           # what an interrupted write leaves behind
aea --version                              # traceback
aea init --reset --author valory --local   # same traceback — cannot self-repair
rm "$HOME/.aea/cli_config.yaml"
aea init --reset --author valory --local   # only now it works again

For the race, one writer thread and one reader thread against a throwaway HOME:

import os, tempfile, threading
os.environ["HOME"] = tempfile.mkdtemp()
os.makedirs(os.path.join(os.environ["HOME"], ".aea"))
from aea.cli.utils import config as cfg

CONFIG = {"author": "valory",
          "registry_config": {"default": "local", "settings": {"local": {}, "remote": {}}}}
failures = []
start = threading.Barrier(2)

def writer():
    start.wait()
    for _ in range(200):
        cfg._init_cli_config(CONFIG)

def reader():
    start.wait()
    for _ in range(400):
        try:
            cfg.get_or_create_cli_config()
        except Exception as e:
            failures.append(e)

t1, t2 = threading.Thread(target=writer), threading.Thread(target=reader)
t1.start(); t2.start(); t1.join(); t2.join()
print(len(failures), "of 400 reads failed")

On open-aea 2.2.9 this prints 205 of 400 reads failed, all ValidationError. The count varies run to run; it has never been zero for me.

Expected behavior

A reader either sees the previous config or the new one, never a half-written one, and an interrupted write leaves the existing config intact.

Writing to a temp file in the same directory and os.replace-ing it into place gives both, since rename within a filesystem is atomic:

def _init_cli_config(config: Optional[Dict] = None) -> None:
    """Create cli config folder and file."""
    config = config or DEFAULT_CLI_CONFIG
    conf_dir = os.path.dirname(CLI_CONFIG_PATH)
    os.makedirs(conf_dir, exist_ok=True)
    fd, tmp_path = tempfile.mkstemp(dir=conf_dir, prefix=".cli_config.", suffix=".tmp")
    os.close(fd)
    try:
        with open_file(tmp_path, "w") as f:
            yaml.dump(config, f, default_flow_style=False)
        os.replace(tmp_path, CLI_CONFIG_PATH)
    except BaseException:
        if os.path.exists(tmp_path):
            os.unlink(tmp_path)
        raise

With that patch applied, the race script above reports 0 of 400 and leaves no temp files behind. The temp file has to live in ~/.aea rather than /tmp, or os.replace can land across a filesystem boundary and fail.

Worth pairing with it: treat an unreadable or invalid cli_config.yaml the way a missing one is already treated, so a corrupt file degrades to the default config with a warning instead of an uncatchable traceback. Otherwise aea init still cannot repair a file corrupted some other way.

Desktop

  • OS: Linux 7.0.0-30-generic
  • AEA Version: 2.2.9 (also present on main, 6089e95ea)

Additional context

Found while auditing CI for the Open Autonomy service repos. Every aea/autonomy command reads this file through registry_flag, and autonomy init --reset writes it in the release workflows, so the blast radius is any environment where those overlap or where a run gets cancelled at the wrong moment — a cancelled GitHub Actions job, a killed container, or several commands sharing one HOME.

Happy to open a PR with the change above if the approach looks right.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions