Skip to content

Commit f30b291

Browse files
ericharemanav2000autofix-ci[bot]
authored
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>
1 parent 23eebd0 commit f30b291

9 files changed

Lines changed: 46 additions & 22 deletions

File tree

src/backend/base/langflow/api/v1/flows_helpers.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from typing import TYPE_CHECKING, Any
1414
from uuid import UUID
1515

16-
from aiofile import async_open
16+
import aiofiles
1717
from anyio import Path
1818
from fastapi import HTTPException
1919
from fastapi.responses import StreamingResponse
@@ -176,8 +176,7 @@ async def _save_flow_to_fs(flow: Flow, user_id: UUID, storage_service: StorageSe
176176
try:
177177
safe_path = _get_safe_flow_path(flow.fs_path, user_id, storage_service)
178178
await safe_path.parent.mkdir(parents=True, exist_ok=True)
179-
# async_open expects a string path, not a Path object
180-
async with async_open(str(safe_path), "w") as f:
179+
async with aiofiles.open(str(safe_path), "w") as f:
181180
await f.write(flow.model_dump_json())
182181
except HTTPException:
183182
raise

src/backend/base/langflow/initial_setup/setup.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
from typing import AnyStr
1414
from uuid import UUID
1515

16+
import aiofiles
1617
import anyio
1718
import httpx
1819
import orjson
1920
import sqlalchemy as sa
20-
from aiofile import async_open
2121
from emoji import demojize, purely_emoji
2222
from lfx.base.constants import (
2323
FIELD_FORMAT_ATTRIBUTES,
@@ -676,7 +676,7 @@ def get_project_data(project):
676676

677677
async def update_project_file(project_path: anyio.Path, project: dict, updated_project_data) -> None:
678678
project["data"] = updated_project_data
679-
async with async_open(str(project_path), "w", encoding="utf-8") as f:
679+
async with aiofiles.open(str(project_path), "w", encoding="utf-8") as f:
680680
await f.write(orjson.dumps(project, option=ORJSON_OPTIONS).decode())
681681
await logger.adebug(f"Updated starter project {project['name']} file")
682682

@@ -803,7 +803,7 @@ async def load_agentic_flows() -> list[tuple[anyio.Path, dict]]:
803803
await logger.adebug("Loading agentic flows")
804804
async for file in folder.glob("*.json"):
805805
try:
806-
async with async_open(str(file), "r", encoding="utf-8") as f:
806+
async with aiofiles.open(str(file), encoding="utf-8") as f:
807807
content = await f.read()
808808
flow = orjson.loads(content)
809809
agentic_flows.append((file, flow))
@@ -961,7 +961,7 @@ async def load_flows_from_directory() -> None:
961961
if not await anyio.Path(file_path).is_file() or file_path.suffix != ".json":
962962
continue
963963
await logger.ainfo(f"Loading flow from file: {file_path.name}")
964-
async with async_open(str(file_path), "r", encoding="utf-8") as f:
964+
async with aiofiles.open(str(file_path), encoding="utf-8") as f:
965965
content = await f.read()
966966
await upsert_flow_from_file(content, file_path.stem, session, user.id)
967967

src/backend/base/langflow/services/flow/flow_runner.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from pathlib import Path
44
from uuid import UUID, uuid4
55

6-
from aiofile import async_open
6+
import aiofiles
77
from lfx.graph import Graph
88
from lfx.graph.vertex.param_handler import ParameterHandler
99
from lfx.log.logger import configure, logger
@@ -254,7 +254,7 @@ async def database_exists_check():
254254
@staticmethod
255255
async def get_flow_dict(flow: Path | str | dict) -> dict:
256256
if isinstance(flow, str | Path):
257-
async with async_open(Path(flow), encoding="utf-8") as f:
257+
async with aiofiles.open(Path(flow), encoding="utf-8") as f:
258258
content = await f.read()
259259
return json.loads(content)
260260
# If input is a dictionary, assume it's a JSON object

src/backend/base/langflow/services/storage/local.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from pathlib import Path
66
from typing import TYPE_CHECKING
77

8-
from aiofile import async_open
8+
import aiofiles
99

1010
from langflow.logging.logger import logger
1111
from langflow.services.storage.service import StorageService
@@ -169,7 +169,7 @@ async def save_file(self, flow_id: str, file_name: str, data: bytes, *, append:
169169

170170
try:
171171
mode = "ab" if append else "wb"
172-
async with async_open(str(file_path), mode) as f:
172+
async with aiofiles.open(str(file_path), mode) as f:
173173
await f.write(data)
174174
action = "appended to" if append else "saved"
175175
await logger.ainfo(f"File {file_name} {action} successfully in flow {flow_id}.")
@@ -196,7 +196,7 @@ async def get_file(self, flow_id: str, file_name: str) -> bytes:
196196
msg = f"File {file_name} not found in flow {flow_id}"
197197
raise FileNotFoundError(msg)
198198

199-
async with async_open(str(file_path), "rb") as f:
199+
async with aiofiles.open(str(file_path), "rb") as f:
200200
content = await f.read()
201201

202202
logger.debug(f"File {file_name} retrieved successfully from flow {flow_id}.")
@@ -210,7 +210,7 @@ async def get_file_stream(self, flow_id: str, file_name: str, chunk_size: int =
210210
msg = f"File {file_name} not found in flow {flow_id}"
211211
raise FileNotFoundError(msg)
212212

213-
async with async_open(str(file_path), "rb") as f:
213+
async with aiofiles.open(str(file_path), "rb") as f:
214214
while True:
215215
chunk = await f.read(chunk_size)
216216
if not chunk:

src/backend/tests/unit/api/v1/test_flows_helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ async def test_save_flow_to_fs_returns_500_on_os_error(current_user, storage_ser
7272
)
7373

7474
with (
75-
patch("langflow.api.v1.flows_helpers.async_open", side_effect=OSError("disk full")),
75+
patch("langflow.api.v1.flows_helpers.aiofiles.open", side_effect=OSError("disk full")),
7676
pytest.raises(HTTPException) as exc_info,
7777
):
7878
await _save_flow_to_fs(flow, current_user.id, storage_service)

src/backend/tests/unit/services/storage/test_local_storage_service.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,3 +361,28 @@ async def test_concurrent_file_operations(self, local_storage_service):
361361
# Verify all files were saved
362362
listed = await local_storage_service.list_files(flow_id)
363363
assert len(listed) == 10
364+
365+
async def test_concurrent_write_then_read(self, local_storage_service):
366+
"""Regression test for SystemError(11, 'Resource temporarily unavailable').
367+
368+
Under concurrent execution, aiofile/caio would leak kernel AIO contexts
369+
causing EAGAIN after ~150-200 runs. This test verifies that writing a file
370+
and immediately reading it back works reliably under concurrency with the
371+
aiofiles backend. See https://github.com/langflow-ai/langflow/issues/12414
372+
"""
373+
flow_id = "concurrent_rw_flow"
374+
num_files = 50
375+
376+
async def write_then_read(i: int) -> None:
377+
file_name = f"file_{i}.bin"
378+
data = f"payload-{i}".encode()
379+
await local_storage_service.save_file(flow_id, file_name, data)
380+
retrieved = await local_storage_service.get_file(flow_id, file_name)
381+
assert retrieved == data, f"file_{i} content mismatch"
382+
383+
async with anyio.create_task_group() as tg:
384+
for i in range(num_files):
385+
tg.start_soon(write_then_read, i)
386+
387+
listed = await local_storage_service.list_files(flow_id)
388+
assert len(listed) == num_files

src/lfx/src/lfx/custom/directory_reader/directory_reader.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import zlib
44
from pathlib import Path
55

6+
import aiofiles
67
import anyio
7-
from aiofile import async_open
88

99
from lfx.custom.custom_component.component import Component
1010
from lfx.log.logger import logger
@@ -117,14 +117,14 @@ async def aread_file_content(self, file_path):
117117
if not await file_path_.is_file():
118118
return None
119119
try:
120-
async with async_open(str(file_path_), encoding="utf-8") as file:
120+
async with aiofiles.open(str(file_path_), encoding="utf-8") as file:
121121
# UnicodeDecodeError: 'charmap' codec can't decode byte 0x9d in position 3069:
122122
# character maps to <undefined>
123123
return await file.read()
124124
except UnicodeDecodeError:
125125
# This is happening in Windows, so we need to open the file in binary mode
126126
# The file is always just a python file, so we can safely read it as utf-8
127-
async with async_open(str(file_path_), "rb") as f:
127+
async with aiofiles.open(str(file_path_), "rb") as f:
128128
return (await f.read()).decode("utf-8")
129129

130130
def get_files(self):

src/lfx/src/lfx/load/load.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from pathlib import Path
44
from typing import TYPE_CHECKING
55

6-
from aiofile import async_open
6+
import aiofiles
77
from dotenv import dotenv_values
88

99
from lfx.graph.schema import RunOutputs
@@ -57,7 +57,7 @@ async def aload_flow_from_json(
5757

5858
# override env variables with .env file
5959
if env_file and tweaks is not None:
60-
async with async_open(Path(env_file), encoding="utf-8") as f:
60+
async with aiofiles.open(Path(env_file), encoding="utf-8") as f:
6161
content = await f.read()
6262
env_vars = dotenv_values(stream=StringIO(content))
6363
tweaks = replace_tweaks_with_env(tweaks=tweaks, env_vars=env_vars)
@@ -66,7 +66,7 @@ async def aload_flow_from_json(
6666
await update_settings(cache=cache)
6767

6868
if isinstance(flow, str | Path):
69-
async with async_open(Path(flow), encoding="utf-8") as f:
69+
async with aiofiles.open(Path(flow), encoding="utf-8") as f:
7070
content = await f.read()
7171
flow_graph = json.loads(content)
7272
# If input is a dictionary, assume it's a JSON object

src/lfx/src/lfx/services/settings/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
from shutil import copy2
77
from typing import Any, Literal
88

9+
import aiofiles
910
import orjson
1011
import yaml
11-
from aiofile import async_open
1212
from pydantic import Field, field_validator
1313
from pydantic.fields import FieldInfo
1414
from pydantic_settings import BaseSettings, EnvSettingsSource, PydanticBaseSettingsSource, SettingsConfigDict
@@ -705,7 +705,7 @@ async def load_settings_from_yaml(file_path: str) -> Settings:
705705
else:
706706
file_path_ = Path(file_path)
707707

708-
async with async_open(file_path_.name, encoding="utf-8") as f:
708+
async with aiofiles.open(file_path_.name, encoding="utf-8") as f:
709709
content = await f.read()
710710
settings_dict = yaml.safe_load(content)
711711
settings_dict = {k.upper(): v for k, v in settings_dict.items()}

0 commit comments

Comments
 (0)