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.
Describe the bug
_init_cli_configwrites~/.aea/cli_config.yamlby truncating the real file in place:https://github.com/valory-xyz/open-aea/blob/v2.2.9/aea/cli/utils/config.py#L121-L128
open(..., "w+")empties the file the moment it is called, and it stays empty untilyaml.dumpfinishes. Anything that stops the process in that window — Ctrl-C, SIGTERM, a full disk, an OOM kill — leaves a zero-bytecli_config.yamlbehind.That is not a recoverable state.
get_or_create_cli_configonly catchesFileNotFoundError, so an empty file is loaded asNone, failsvalidate_cli_config, and raises. The read happens inregistry_flagat the click-group level, so it runs before any command body does. Everyaeaandautonomycommand then crashes with an unhandled traceback, includingaea --version, and includingaea init --reset— the command you would reach for to repair it. The only way out is knowing torm ~/.aea/cli_config.yamlby hand.The same window is also a read/write race: a second
aeaprocess sharing the sameHOMEcan read the file while a first one is rewriting it, and gets the sameValidationError.To Reproduce
The permanent-corruption case needs no concurrency and no interrupt — an empty file is the whole reproduction:
For the race, one writer thread and one reader thread against a throwaway
HOME: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, sincerenamewithin a filesystem is atomic: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
~/.aearather than/tmp, oros.replacecan land across a filesystem boundary and fail.Worth pairing with it: treat an unreadable or invalid
cli_config.yamlthe way a missing one is already treated, so a corrupt file degrades to the default config with a warning instead of an uncatchable traceback. Otherwiseaea initstill cannot repair a file corrupted some other way.Desktop
main,6089e95ea)Additional context
Found while auditing CI for the Open Autonomy service repos. Every
aea/autonomycommand reads this file throughregistry_flag, andautonomy init --resetwrites 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 oneHOME.Happy to open a PR with the change above if the approach looks right.