fix: Replace aiofile with aiofiles to prevent caio context leak under concurrent execution (#12525)

* fix: replace aiofile with aiofiles to prevent caio context leak under concurrent execution

aiofile uses caio (kernel AIO) which creates contexts in a global dict
that are never cleaned up. Under concurrent execution these accumulate
until the OS aio-max-nr limit is exhausted, causing
SystemError(11, 'Resource temporarily unavailable'). aiofiles uses
thread pools instead and does not have this issue.

Migrates all aiofile.async_open usages across both backend and lfx
packages to aiofiles.open.

Based on #12433 by @manav2000, extended to cover all remaining usages.

Co-Authored-By: manav2000 <manav2000@users.noreply.github.com>

* test: add concurrent write-then-read regression test for caio EAGAIN fix

Exercises the exact failure pattern from #12414: multiple concurrent
save-then-immediately-read operations on the storage service. This
would previously trigger SystemError(11, EAGAIN) after ~150-200 runs
with the aiofile/caio backend.

Co-Authored-By: manav2000 <manav2000@users.noreply.github.com>

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

---------

Co-authored-by: manav2000 <manav2000@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Eric Hare
2026-04-07 16:12:19 -07:00
committed by GitHub
parent 23eebd037d
commit f30b291f80
9 changed files with 46 additions and 22 deletions

View File

@ -13,7 +13,7 @@ from pathlib import Path as StdlibPath
from typing import TYPE_CHECKING, Any
from uuid import UUID
from aiofile import async_open
import aiofiles
from anyio import Path
from fastapi import HTTPException
from fastapi.responses import StreamingResponse
@ -176,8 +176,7 @@ async def _save_flow_to_fs(flow: Flow, user_id: UUID, storage_service: StorageSe
try:
safe_path = _get_safe_flow_path(flow.fs_path, user_id, storage_service)
await safe_path.parent.mkdir(parents=True, exist_ok=True)
# async_open expects a string path, not a Path object
async with async_open(str(safe_path), "w") as f:
async with aiofiles.open(str(safe_path), "w") as f:
await f.write(flow.model_dump_json())
except HTTPException:
raise

View File

@ -13,11 +13,11 @@ from tempfile import TemporaryDirectory
from typing import AnyStr
from uuid import UUID
import aiofiles
import anyio
import httpx
import orjson
import sqlalchemy as sa
from aiofile import async_open
from emoji import demojize, purely_emoji
from lfx.base.constants import (
FIELD_FORMAT_ATTRIBUTES,
@ -676,7 +676,7 @@ def get_project_data(project):
async def update_project_file(project_path: anyio.Path, project: dict, updated_project_data) -> None:
project["data"] = updated_project_data
async with async_open(str(project_path), "w", encoding="utf-8") as f:
async with aiofiles.open(str(project_path), "w", encoding="utf-8") as f:
await f.write(orjson.dumps(project, option=ORJSON_OPTIONS).decode())
await logger.adebug(f"Updated starter project {project['name']} file")
@ -803,7 +803,7 @@ async def load_agentic_flows() -> list[tuple[anyio.Path, dict]]:
await logger.adebug("Loading agentic flows")
async for file in folder.glob("*.json"):
try:
async with async_open(str(file), "r", encoding="utf-8") as f:
async with aiofiles.open(str(file), encoding="utf-8") as f:
content = await f.read()
flow = orjson.loads(content)
agentic_flows.append((file, flow))
@ -961,7 +961,7 @@ async def load_flows_from_directory() -> None:
if not await anyio.Path(file_path).is_file() or file_path.suffix != ".json":
continue
await logger.ainfo(f"Loading flow from file: {file_path.name}")
async with async_open(str(file_path), "r", encoding="utf-8") as f:
async with aiofiles.open(str(file_path), encoding="utf-8") as f:
content = await f.read()
await upsert_flow_from_file(content, file_path.stem, session, user.id)

View File

@ -3,7 +3,7 @@ import os
from pathlib import Path
from uuid import UUID, uuid4
from aiofile import async_open
import aiofiles
from lfx.graph import Graph
from lfx.graph.vertex.param_handler import ParameterHandler
from lfx.log.logger import configure, logger
@ -254,7 +254,7 @@ class LangflowRunnerExperimental:
@staticmethod
async def get_flow_dict(flow: Path | str | dict) -> dict:
if isinstance(flow, str | Path):
async with async_open(Path(flow), encoding="utf-8") as f:
async with aiofiles.open(Path(flow), encoding="utf-8") as f:
content = await f.read()
return json.loads(content)
# If input is a dictionary, assume it's a JSON object

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from aiofile import async_open
import aiofiles
from langflow.logging.logger import logger
from langflow.services.storage.service import StorageService
@ -169,7 +169,7 @@ class LocalStorageService(StorageService):
try:
mode = "ab" if append else "wb"
async with async_open(str(file_path), mode) as f:
async with aiofiles.open(str(file_path), mode) as f:
await f.write(data)
action = "appended to" if append else "saved"
await logger.ainfo(f"File {file_name} {action} successfully in flow {flow_id}.")
@ -196,7 +196,7 @@ class LocalStorageService(StorageService):
msg = f"File {file_name} not found in flow {flow_id}"
raise FileNotFoundError(msg)
async with async_open(str(file_path), "rb") as f:
async with aiofiles.open(str(file_path), "rb") as f:
content = await f.read()
logger.debug(f"File {file_name} retrieved successfully from flow {flow_id}.")
@ -210,7 +210,7 @@ class LocalStorageService(StorageService):
msg = f"File {file_name} not found in flow {flow_id}"
raise FileNotFoundError(msg)
async with async_open(str(file_path), "rb") as f:
async with aiofiles.open(str(file_path), "rb") as f:
while True:
chunk = await f.read(chunk_size)
if not chunk:

View File

@ -72,7 +72,7 @@ async def test_save_flow_to_fs_returns_500_on_os_error(current_user, storage_ser
)
with (
patch("langflow.api.v1.flows_helpers.async_open", side_effect=OSError("disk full")),
patch("langflow.api.v1.flows_helpers.aiofiles.open", side_effect=OSError("disk full")),
pytest.raises(HTTPException) as exc_info,
):
await _save_flow_to_fs(flow, current_user.id, storage_service)

View File

@ -361,3 +361,28 @@ class TestLocalStorageServiceEdgeCases:
# Verify all files were saved
listed = await local_storage_service.list_files(flow_id)
assert len(listed) == 10
async def test_concurrent_write_then_read(self, local_storage_service):
"""Regression test for SystemError(11, 'Resource temporarily unavailable').
Under concurrent execution, aiofile/caio would leak kernel AIO contexts
causing EAGAIN after ~150-200 runs. This test verifies that writing a file
and immediately reading it back works reliably under concurrency with the
aiofiles backend. See https://github.com/langflow-ai/langflow/issues/12414
"""
flow_id = "concurrent_rw_flow"
num_files = 50
async def write_then_read(i: int) -> None:
file_name = f"file_{i}.bin"
data = f"payload-{i}".encode()
await local_storage_service.save_file(flow_id, file_name, data)
retrieved = await local_storage_service.get_file(flow_id, file_name)
assert retrieved == data, f"file_{i} content mismatch"
async with anyio.create_task_group() as tg:
for i in range(num_files):
tg.start_soon(write_then_read, i)
listed = await local_storage_service.list_files(flow_id)
assert len(listed) == num_files

View File

@ -3,8 +3,8 @@ import asyncio
import zlib
from pathlib import Path
import aiofiles
import anyio
from aiofile import async_open
from lfx.custom.custom_component.component import Component
from lfx.log.logger import logger
@ -117,14 +117,14 @@ class DirectoryReader:
if not await file_path_.is_file():
return None
try:
async with async_open(str(file_path_), encoding="utf-8") as file:
async with aiofiles.open(str(file_path_), encoding="utf-8") as file:
# UnicodeDecodeError: 'charmap' codec can't decode byte 0x9d in position 3069:
# character maps to <undefined>
return await file.read()
except UnicodeDecodeError:
# This is happening in Windows, so we need to open the file in binary mode
# The file is always just a python file, so we can safely read it as utf-8
async with async_open(str(file_path_), "rb") as f:
async with aiofiles.open(str(file_path_), "rb") as f:
return (await f.read()).decode("utf-8")
def get_files(self):

View File

@ -3,7 +3,7 @@ from io import StringIO
from pathlib import Path
from typing import TYPE_CHECKING
from aiofile import async_open
import aiofiles
from dotenv import dotenv_values
from lfx.graph.schema import RunOutputs
@ -57,7 +57,7 @@ async def aload_flow_from_json(
# override env variables with .env file
if env_file and tweaks is not None:
async with async_open(Path(env_file), encoding="utf-8") as f:
async with aiofiles.open(Path(env_file), encoding="utf-8") as f:
content = await f.read()
env_vars = dotenv_values(stream=StringIO(content))
tweaks = replace_tweaks_with_env(tweaks=tweaks, env_vars=env_vars)
@ -66,7 +66,7 @@ async def aload_flow_from_json(
await update_settings(cache=cache)
if isinstance(flow, str | Path):
async with async_open(Path(flow), encoding="utf-8") as f:
async with aiofiles.open(Path(flow), encoding="utf-8") as f:
content = await f.read()
flow_graph = json.loads(content)
# If input is a dictionary, assume it's a JSON object

View File

@ -6,9 +6,9 @@ from pathlib import Path
from shutil import copy2
from typing import Any, Literal
import aiofiles
import orjson
import yaml
from aiofile import async_open
from pydantic import Field, field_validator
from pydantic.fields import FieldInfo
from pydantic_settings import BaseSettings, EnvSettingsSource, PydanticBaseSettingsSource, SettingsConfigDict
@ -705,7 +705,7 @@ async def load_settings_from_yaml(file_path: str) -> Settings:
else:
file_path_ = Path(file_path)
async with async_open(file_path_.name, encoding="utf-8") as f:
async with aiofiles.open(file_path_.name, encoding="utf-8") as f:
content = await f.read()
settings_dict = yaml.safe_load(content)
settings_dict = {k.upper(): v for k, v in settings_dict.items()}