Pytest: Possible race-condition while writing bytecode when rewriting assertions

Created on 7 Dec 2017  路  5Comments  路  Source: pytest-dev/pytest

Since we started porting to Python 3, our builds on Windows fail sometimes, say 5-10% of the runs, in a bizarre and random manner:

___________ ERROR at setup of testMeshAssemblyPlaneControllerNoBBox ___________
[gw3] win32 -- Python 3.5.3 W:\Miniconda\envs\_sci20-win64g-py35\python.exe
file K:\etk\sci20\source\python\sci20\app\mesh_assembly\plane\_tests\test_mesh_assembly_plane_controller.py, line 83
  def testMeshAssemblyPlaneControllerNoBBox(almost_equal):
E       fixture 'py5' not found

Strangely the "name" being reported is always py and some digits, like py2, py7 or py44.

This always happens when running with xdist and in a random test or contest file, which suggest that the assertion rewriting code might some bug in its locking code when writing the rewritten bytecode to the disk, corrupting it.

windows rewrite bug

Most helpful comment

The code responsible for writing the pyc files is this:

https://github.com/pytest-dev/pytest/blob/e012dbe346d724d91993068c6f5cb15423bb167e/_pytest/assertion/rewrite.py#L341-L352

Unfortunately the comment about Windows is not correct, there's no exclusive access guarantee and it is possible for concurrent processes/threads to write to a file at the same time and possibly corrupt it.

This short script demonstrates this:

import os
import tempfile
import threading

with tempfile.TemporaryDirectory() as tmpdir:
    filename = os.path.join(tmpdir, 'somefile.txt')
    print(f'FILE: {filename}')

    COUNT = 10

    data = [1025 * 1024 * str(i) for i in range(COUNT)]


    def write(i):
        with open(filename, 'w') as f:
            f.write(data[i])

    threads = [threading.Thread(target=write, args=(i,)) for i in range(COUNT)]
    for t in threads:
        t.start()

    for t in threads:
        t.join()

    print('DONE')

    with open(filename) as f:
        read_data = set(f.read())

    print(f'READ: {read_data}')

It spawns a number of threads, all of them writing different data to the same file. At the end, it counts how many different characters were written to the file.

Most of the time the output is this:

FILE: C:\Users\Bruno\AppData\Local\Temp\tmpzzgwfa99\somefile.txt
DONE
READ: {'2'}

But sometimes (2/9 on my machine) the file ends up corrupted:

FILE: C:\Users\Bruno\AppData\Local\Temp\tmpm0o7bpuq\somefile.txt
DONE
READ: {'8', '9'}

This behavior is consistent with the CI failures we have seen: corrupted .pyc files around 2-3% of the time.

I searched for some solutions about the problem of atomically renaming a file on multiple platforms and the best library I found was python-atomicwrites, which is small and does just this job.

All 5 comments

Also, it seems it always changes an identifier to something else, rather than corrupting actual code.

The code responsible for writing the pyc files is this:

https://github.com/pytest-dev/pytest/blob/e012dbe346d724d91993068c6f5cb15423bb167e/_pytest/assertion/rewrite.py#L341-L352

Unfortunately the comment about Windows is not correct, there's no exclusive access guarantee and it is possible for concurrent processes/threads to write to a file at the same time and possibly corrupt it.

This short script demonstrates this:

import os
import tempfile
import threading

with tempfile.TemporaryDirectory() as tmpdir:
    filename = os.path.join(tmpdir, 'somefile.txt')
    print(f'FILE: {filename}')

    COUNT = 10

    data = [1025 * 1024 * str(i) for i in range(COUNT)]


    def write(i):
        with open(filename, 'w') as f:
            f.write(data[i])

    threads = [threading.Thread(target=write, args=(i,)) for i in range(COUNT)]
    for t in threads:
        t.start()

    for t in threads:
        t.join()

    print('DONE')

    with open(filename) as f:
        read_data = set(f.read())

    print(f'READ: {read_data}')

It spawns a number of threads, all of them writing different data to the same file. At the end, it counts how many different characters were written to the file.

Most of the time the output is this:

FILE: C:\Users\Bruno\AppData\Local\Temp\tmpzzgwfa99\somefile.txt
DONE
READ: {'2'}

But sometimes (2/9 on my machine) the file ends up corrupted:

FILE: C:\Users\Bruno\AppData\Local\Temp\tmpm0o7bpuq\somefile.txt
DONE
READ: {'8', '9'}

This behavior is consistent with the CI failures we have seen: corrupted .pyc files around 2-3% of the time.

I searched for some solutions about the problem of atomically renaming a file on multiple platforms and the best library I found was python-atomicwrites, which is small and does just this job.

Hopefully fixed in #3390. Will re-open if I see this problem again in pytest>=3.6.

great job for debugging this. Can't actually :+1: enough! Thanks!

@maiksensi were you experiencing this as well?

Was this page helpful?
0 / 5 - 0 ratings