Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions flashinfer/jit/cubin_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,16 +241,20 @@ def ensure_symlink(
"""
link = pathlib.Path(link)
target = pathlib.Path(target)
if link.is_symlink() or link.exists():
if link.is_symlink() and link.resolve() == target.resolve():
return # already correct
# Stale symlink or directory from a previous version; remove it.
if link.is_symlink() or link.is_file():
link.unlink()
else:
shutil.rmtree(link)

link.parent.mkdir(parents=True, exist_ok=True)
link.symlink_to(target)
lock_path = str(link) + ".lock"
lock = filelock.FileLock(lock_path, timeout=60)
with lock:
if link.is_symlink() or link.exists():
if link.is_symlink() and link.resolve() == target.resolve():
return # already correct
# Stale symlink or directory from a previous version; remove it.
if link.is_symlink() or link.is_file():
link.unlink()
else:
shutil.rmtree(link)
link.symlink_to(target)
Comment on lines +244 to +257

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The filelock.FileLock constructor will fail with a FileNotFoundError if the parent directory of the lock_path does not exist. Since ensure_symlink is responsible for creating the directory structure, the mkdir call should be moved before the lock creation to ensure the lock file can be created successfully.

Additionally, once the parent directory is created and the lock is acquired, the redundant mkdir call inside the lock can be removed, as the parent directory is guaranteed to exist (and the open lock file prevents the parent directory from being removed by standard directory removal operations).

Suggested change
lock_path = str(link) + ".lock"
lock = filelock.FileLock(lock_path, timeout=60)
with lock:
if link.is_symlink() or link.exists():
if link.is_symlink() and link.resolve() == target.resolve():
return # already correct
# Stale symlink or directory from a previous version; remove it.
if link.is_symlink() or link.is_file():
link.unlink()
else:
shutil.rmtree(link)
link.parent.mkdir(parents=True, exist_ok=True)
link.symlink_to(target)
link.parent.mkdir(parents=True, exist_ok=True)
lock_path = str(link) + ".lock"
lock = filelock.FileLock(lock_path, timeout=60)
with lock:
if link.is_symlink() or link.exists():
if link.is_symlink() and link.resolve() == target.resolve():
return # already correct
# Stale symlink or directory from a previous version; remove it.
if link.is_symlink() or link.is_file():
link.unlink()
else:
shutil.rmtree(link)
link.symlink_to(target)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed. can you check again?

Comment on lines +247 to +257

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify direct ensure_symlink call sites and lack of local timeout handling context.
set -euo pipefail
rg -n -C3 '\bensure_symlink\s*\(' --type=py
rg -n -C3 'except\s+filelock\.Timeout|except\s+Timeout' --type=py

Repository: flashinfer-ai/flashinfer

Length of output: 2036


🏁 Script executed:

# Get the full ensure_symlink function and surrounding context
sed -n '233,260p' flashinfer/jit/cubin_loader.py

Repository: flashinfer-ai/flashinfer

Length of output: 1127


🏁 Script executed:

# Get context around line 134 to understand which function it's in
sed -n '120,150p' flashinfer/jit/cubin_loader.py

Repository: flashinfer-ai/flashinfer

Length of output: 1269


🏁 Script executed:

# Check the full context of ensure_symlink calls in moe_utils.py
sed -n '55,70p' flashinfer/jit/moe_utils.py

Repository: flashinfer-ai/flashinfer

Length of output: 609


🏁 Script executed:

# Check the full context of ensure_symlink calls in fused_moe.py
sed -n '270,285p' flashinfer/jit/fused_moe.py

Repository: flashinfer-ai/flashinfer

Length of output: 565


Handle filelock.Timeout inside ensure_symlink to prevent uncaught exceptions during JIT module generation under contention.

The function creates a filelock.FileLock with 60-second timeout (line 247) but lacks exception handling. If lock acquisition times out, the exception propagates uncaught to callers like moe_utils.py:63 and fused_moe.py:277, which do not catch it. Under concurrent access, module generation may fail abruptly.

Wrap the lock block with try-except, check if the symlink was created correctly by another process, and raise a clearer error if the timeout is genuine.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flashinfer/jit/cubin_loader.py` around lines 246 - 257, The ensure_symlink
function currently creates a filelock.FileLock(lock_path, timeout=60) and enters
with lock but does not handle filelock.Timeout; wrap the with-lock block in a
try/except catching filelock.Timeout (and optionally filelock.AlreadyLocked)
around the context manager usage for the lock created at lock_path, and inside
the except check whether the symlink (link) already exists and resolves to the
desired target (target.resolve() == link.resolve()); if so, return normally,
otherwise raise a new, clearer RuntimeError (or re-raise a wrapped exception)
indicating lock acquisition timed out while attempting to create the symlink for
the JIT cubin, so callers like moe_utils and fused_moe get a descriptive error
instead of an uncaught filelock.Timeout.



def verify_symlinked_headers(
Expand Down
129 changes: 129 additions & 0 deletions tests/utils/test_gen_module_symlink_race_condition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""
Copyright (c) 2026 by FlashInfer team.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""

import os
import tempfile
from pathlib import Path
from multiprocessing import Pool


def gen_fused_moe_worker_process(temp_dir):
"""
Worker function that calls gen_trtllm_gen_fused_moe_sm100_module end-to-end.

Each process will:
1. Patch FLASHINFER_CUBIN_DIR in all modules to use the shared temp directory
2. Call gen_trtllm_gen_fused_moe_sm100_module (downloads artifacts, creates symlinks)
3. Verify the symlink is correct
"""
from flashinfer.jit import env as jit_env
from flashinfer.jit import cubin_loader

jit_env.FLASHINFER_CUBIN_DIR = Path(temp_dir)
cubin_loader.FLASHINFER_CUBIN_DIR = Path(temp_dir)

from flashinfer.jit.fused_moe import gen_trtllm_gen_fused_moe_sm100_module

gen_trtllm_gen_fused_moe_sm100_module()

# Verify the symlink was created correctly.
symlink_path = (
Path(temp_dir)
/ "flashinfer"
/ "trtllm"
/ "batched_gemm"
/ "trtllmGen_bmm_export"
)
assert symlink_path.is_symlink(), f"Expected {symlink_path} to be a symlink"

# Verify we can read a header through the symlink
headers = [p for p in symlink_path.iterdir() if p.is_file()]
assert len(headers) > 0, f"No headers found through symlink at {symlink_path}"
for header in headers:
content = header.read_bytes()
assert len(content) > 0, f"Header {header.name} is empty"

return True


def test_gen_fused_moe_symlink_race_condition(num_iterations=100, num_processes=10):
"""
End-to-end test for race conditions in gen_trtllm_gen_fused_moe_sm100_module.

Multiple processes concurrently call the real gen_trtllm_gen_fused_moe_sm100_module,
which downloads artifacts via get_artifact() and races on ensure_symlink().

Uses a single shared temp directory across iterations so artifacts are cached
after the first download. Between iterations, the symlink is deleted to
re-trigger the race condition without re-downloading.

Args:
num_iterations: Number of times to repeat the test
num_processes: Number of concurrent processes per iteration
"""
import shutil
import torch
from flashinfer.utils import is_sm100a_supported, is_sm12x_supported

device = torch.device("cuda")
if not (is_sm100a_supported(device) or is_sm12x_supported(device)):
print("Skipping: gen_trtllm_gen_fused_moe_sm100_module requires SM100 or SM12x")
return
Comment on lines +81 to +88

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n tests/utils/test_gen_module_symlink_race_condition.py | head -100

Repository: flashinfer-ai/flashinfer

Length of output: 4138


🏁 Script executed:

# Check how other tests in the repository handle skipping on unsupported architectures
rg "pytest\.skip\(|is_sm.*_supported" tests/ -A 2 -B 2 | head -100

Repository: flashinfer-ai/flashinfer

Length of output: 7658


🏁 Script executed:

# Check if there are other test functions in the file that might show patterns
rg "def test_|def gen_" tests/utils/test_gen_module_symlink_race_condition.py

Repository: flashinfer-ai/flashinfer

Length of output: 196


Use pytest.skip() instead of bare return.

A bare return reports this as passed on unsupported GPUs, causing CI to lose visibility that the test never ran. The codebase pattern (e.g., tests/attention/test_trtllm_gen_attention.py) consistently uses pytest.skip() for this purpose. Since test_gen_fused_moe_symlink_race_condition() is a pytest test function, apply the same pattern:

Suggested change
     import shutil
+    import pytest
     import torch
     from flashinfer.utils import is_sm100a_supported, is_sm12x_supported

     device = torch.device("cuda")
     if not (is_sm100a_supported(device) or is_sm12x_supported(device)):
-        print("Skipping: gen_trtllm_gen_fused_moe_sm100_module requires SM100 or SM12x")
-        return
+        pytest.skip(
+            "gen_trtllm_gen_fused_moe_sm100_module requires SM100 or SM12x"
+        )
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/utils/test_gen_module_symlink_race_condition.py` around lines 81 - 88,
The test uses a bare return when GPU support checks fail which hides skipped
tests; in the test function test_gen_fused_moe_symlink_race_condition replace
the early return after calling is_sm100a_supported(device) or
is_sm12x_supported(device) with pytest.skip(...) so the test is reported as
skipped (import pytest at top if needed) and include a clear message like
"Skipping: gen_trtllm_gen_fused_moe_sm100_module requires SM100 or SM12x" to
match existing patterns.


temp_dir = tempfile.mkdtemp(prefix="flashinfer_test_fused_moe_symlink_")
symlink_path = (
Path(temp_dir)
/ "flashinfer"
/ "trtllm"
/ "batched_gemm"
/ "trtllmGen_bmm_export"
)

try:
with Pool(processes=num_processes) as pool:
for iteration in range(num_iterations):
# Delete the symlink to re-trigger the race, but keep
# downloaded artifacts cached in temp_dir.
if symlink_path.is_symlink():
symlink_path.unlink()

results = pool.map(
gen_fused_moe_worker_process,
[temp_dir] * num_processes,
)

assert all(results), (
f"Iteration {iteration + 1}/{num_iterations}: some processes failed"
)

if (iteration + 1) % 10 == 0 or iteration == 0:
print(
f"Iteration {iteration + 1}/{num_iterations}: "
f"{num_processes} processes all verified symlink successfully"
)

print(
f"\nAll gen_fused_moe symlink race tests passed: "
f"{num_iterations} iterations Γ— {num_processes} processes"
)

finally:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)


if __name__ == "__main__":
test_gen_fused_moe_symlink_race_condition()
Loading