Skip to content

Commit 60c3887

Browse files
committed
fix: isolate concurrent file tool calls
1 parent b696477 commit 60c3887

7 files changed

Lines changed: 57 additions & 23 deletions

File tree

src/backend/base/langflow/initial_setup/starter_projects/Financial Report Parser.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

src/backend/base/langflow/initial_setup/starter_projects/Hybrid Search RAG.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

src/backend/base/langflow/initial_setup/starter_projects/Portfolio Website Code Generator.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

src/backend/base/langflow/initial_setup/starter_projects/Text Sentiment Analysis.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

src/backend/tests/unit/components/files_and_knowledge/test_file_component.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import asyncio
12
import json
23
import subprocess
34
import tempfile
5+
import threading
6+
from concurrent.futures import ThreadPoolExecutor
47
from unittest.mock import MagicMock, patch
58

69
import pandas as pd
@@ -644,6 +647,38 @@ async def test_tool_execution_reads_supplied_file_path(self, component_class, tm
644647
assert result == "Content from the tool path."
645648
assert component.file_path_str == previous_file_path
646649

650+
@pytest.mark.asyncio
651+
async def test_concurrent_tool_calls_isolate_file_paths(self, component_class, tmp_path):
652+
"""Keep concurrent tool paths isolated on independent component copies."""
653+
component = component_class()
654+
component._attributes["path"] = []
655+
tools = await component._get_tools()
656+
tool = tools[0]
657+
first_path = str(tmp_path / "first.txt")
658+
second_path = str(tmp_path / "second.txt")
659+
entered_loader = threading.Barrier(2, timeout=5)
660+
captured_path = threading.Barrier(2, timeout=5)
661+
662+
def load_files_message(invocation):
663+
entered_loader.wait()
664+
result = Data(text=invocation.file_path_str)
665+
captured_path.wait()
666+
return result
667+
668+
def invoke_tool(file_path: str) -> str:
669+
return asyncio.run(tool.coroutine(file_path_str=file_path))
670+
671+
with (
672+
patch.object(component_class, "load_files_message", load_files_message),
673+
ThreadPoolExecutor(max_workers=2) as executor,
674+
):
675+
first = executor.submit(invoke_tool, first_path)
676+
second = executor.submit(invoke_tool, second_path)
677+
results = {first.result(timeout=10), second.result(timeout=10)}
678+
679+
assert results == {first_path, second_path}
680+
assert component.file_path_str == ""
681+
647682
@pytest.mark.asyncio
648683
async def test_advanced_tool_execution_restores_markdown_mode(self, component_class):
649684
"""Restore the configured output mode after an advanced tool call."""
@@ -672,8 +707,7 @@ async def test_tool_execution_confines_supplied_file_path_when_restricted(self,
672707
settings_service.settings.restrict_local_file_access = True
673708
settings_service.settings.config_dir = config_dir
674709
settings_service.settings.database_url = ""
675-
component = component_class()
676-
component._user_id = "user-1"
710+
component = component_class(_user_id="user-1")
677711
previous_file_path = component.file_path_str
678712

679713
tools = await component._get_tools()

src/lfx/src/lfx/_assets/component_index.json

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

src/lfx/src/lfx/components/files_and_knowledge/file.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -338,29 +338,29 @@ class ReadFilesSchema(BaseModel):
338338

339339
async def read_files_tool(file_path_str: str | None = None) -> str:
340340
"""Read a supplied path or the files uploaded through the UI."""
341-
previous_file_path = getattr(self, "file_path_str", None)
342-
previous_markdown = getattr(self, "markdown", False)
343-
self.file_path_str = file_path_str
341+
invocation = deepcopy(self)
342+
invocation.advanced_mode = getattr(self, "advanced_mode", False)
343+
vertex = self.get_vertex()
344+
if vertex is not None:
345+
invocation.set_vertex(vertex)
346+
invocation.file_path_str = file_path_str
344347
try:
345-
if getattr(self, "advanced_mode", False):
348+
if getattr(invocation, "advanced_mode", False):
346349
# In advanced mode, use the markdown output path so that the
347350
# tool shares the same Docling processing as the advanced
348351
# outputs rather than triggering a second subprocess via
349352
# load_files_message.
350-
self.markdown = True
351-
result = self.load_files_markdown()
353+
invocation.markdown = True
354+
result = invocation.load_files_markdown()
352355
else:
353-
result = self.load_files_message()
356+
result = invocation.load_files_message()
354357
if hasattr(result, "get_text"):
355358
return result.get_text()
356359
if hasattr(result, "text"):
357360
return result.text
358361
return str(result)
359362
except (FileNotFoundError, ValueError, OSError, RuntimeError) as e:
360363
return f"Error reading files: {e}"
361-
finally:
362-
self.file_path_str = previous_file_path
363-
self.markdown = previous_markdown
364364

365365
description = self.get_tool_description()
366366

0 commit comments

Comments
 (0)