-
-
Notifications
You must be signed in to change notification settings - Fork 3k
Reliable version of os.replace for Windows (wait/retry loop) #3239
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
ff27b6e
b089a79
42c6987
2d369c0
8e61b79
beb453f
2f3e380
4950918
a608f0e
0cc6337
f05647c
f9de4c9
199a103
a21f6b5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
from typing import Iterator, List, Tuple | ||
import time | ||
import os | ||
import sys | ||
import tempfile | ||
from contextlib import contextmanager | ||
from threading import Thread | ||
from unittest import TestCase, main, skipUnless | ||
from mypy import util | ||
from mypy.build import random_string | ||
|
||
|
||
WIN32 = sys.platform.startswith("win") | ||
|
||
|
||
def lock_file(filename: str, duration: float) -> Thread: | ||
"""Open a file and keep it open in a background thread for duration sec.""" | ||
def _lock_file() -> None: | ||
with open(filename): | ||
time.sleep(duration) | ||
t = Thread(target=_lock_file, daemon=True) | ||
t.start() | ||
return t | ||
|
||
|
||
@skipUnless(WIN32, "only relevant for Windows") | ||
class WindowsReplace(TestCase): | ||
tmpdir = tempfile.TemporaryDirectory(prefix='mypy-test-', | ||
dir=os.path.abspath('tmp-test-dirs')) | ||
# Choose timeout value that would ensure actual wait inside util.replace is close to timeout | ||
timeout = 0.0009 * 2 ** 10 | ||
short_lock = timeout / 4 | ||
long_lock = timeout * 2 | ||
|
||
threads = [] # type: List[Thread] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What would happen if you made tmpdir and threads just instance variables, resetting and cleaning them for each test case rather than once for all test cases in the class? You have only one test_* method anyways, so...??? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd still like you to ponder this, but it's optional at this point. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (FWIW that was actually an old comment that GitHub had somehow hung on to. As were several other comments on code you had already ripped out... Sorry about the noise!) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should become an instance variable. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My idea was that the entire file locking arrangement is shared across all the Note: I temporarily reduced the number of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I went back to having 4 tests, since otherwise it's too messy. There's no semantic problem with that, and I'll deal with the extra few seconds in test runtime later. But with this, I'd like to keep the class-level attributes if you don't see an issue with them; both semantically and performance-wise, I don't want to force each individual test to wait for the threads to expire. |
||
|
||
@classmethod | ||
def tearDownClass(cls) -> None: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please switch to (instance-level) tearDown. |
||
# Need to wait for threads to complete, otherwise we'll get PermissionError | ||
# at the end (whether tmpdir goes out of scope or we explicitly call cleanup). | ||
for t in cls.threads: | ||
t.join() | ||
cls.tmpdir.cleanup() | ||
|
||
def prepare_src_dest(self, src_lock_duration: float, dest_lock_duration: float | ||
) -> Tuple[str, str]: | ||
# Create two temporary files with random names. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make this into a docstring and add that this also spawns threads that lock each of them for the specified duration. Also mention the content of each is unique. |
||
src = os.path.join(self.tmpdir.name, random_string()) | ||
dest = os.path.join(self.tmpdir.name, random_string()) | ||
|
||
for fname in (src, dest): | ||
with open(fname, 'w') as f: | ||
f.write(fname) | ||
|
||
self.threads.append(lock_file(src, src_lock_duration)) | ||
self.threads.append(lock_file(dest, dest_lock_duration)) | ||
return src, dest | ||
|
||
def replace_ok(self, src_lock_duration: float, dest_lock_duration: float, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a docstring for this. |
||
timeout: float) -> None: | ||
src, dest = self.prepare_src_dest(src_lock_duration, dest_lock_duration) | ||
util._replace(src, dest, timeout=timeout) | ||
self.assertEqual(open(dest).read(), src, 'replace failed') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should use |
||
|
||
def test_everything(self) -> None: | ||
# No files locked. | ||
self.replace_ok(0, 0, self.timeout) | ||
# Make sure we can reproduce the issue with our setup. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There doesn't appear to be any comment to tell us what "the issue" is. Maybe add a reference to the original GitHub issue? |
||
src, dest = self.prepare_src_dest(self.short_lock, 0) | ||
with self.assertRaises(PermissionError): | ||
os.replace(src, dest) | ||
# Lock files for a time short enough that util.replace won't timeout. | ||
self.replace_ok(self.short_lock, 0, self.timeout) | ||
self.replace_ok(0, self.short_lock, self.timeout) | ||
# Lock files for a time long enough that util.replace times out. | ||
with self.assertRaises(PermissionError): | ||
self.replace_ok(self.long_lock, 0, self.timeout) | ||
with self.assertRaises(PermissionError): | ||
self.replace_ok(0, self.long_lock, self.timeout) | ||
|
||
|
||
if __name__ == '__main__': | ||
main() |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,8 +2,16 @@ | |
|
||
import re | ||
import subprocess | ||
import os | ||
import sys | ||
import math | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yo no longer need this import, nor |
||
import time | ||
from itertools import count | ||
from xml.sax.saxutils import escape | ||
from typing import TypeVar, List, Tuple, Optional, Sequence, Dict | ||
from typing import ( | ||
TypeVar, List, Tuple, Optional, Sequence, Dict, Callable, Iterable, Type, Union, | ||
AnyStr, Generic | ||
) | ||
|
||
|
||
T = TypeVar('T') | ||
|
@@ -134,3 +142,39 @@ def id(self, o: object) -> int: | |
self.id_map[o] = self.next_id | ||
self.next_id += 1 | ||
return self.id_map[o] | ||
|
||
|
||
if sys.version_info >= (3, 6): | ||
PathType = Union[AnyStr, 'os.PathLike[AnyStr]'] | ||
else: | ||
# Workaround to allow the use of generic in _replace | ||
class _PathType(Generic[AnyStr]): ... | ||
PathType = _PathType[AnyStr] | ||
|
||
|
||
def _replace(src: PathType[AnyStr], dest: PathType[AnyStr], timeout: float = 1) -> None: | ||
"""Replace src with dest using os.replace(src, dest). | ||
|
||
Wait and retry if OSError exception is raised. | ||
|
||
Increase wait time exponentially until total wait of timeout sec; | ||
on timeout, give up and reraise the last exception seen. | ||
""" | ||
wait = 0.001 | ||
while True: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @gvanrossum Moving the discussion here. I could just make the time allowance in test much larger. That way it will pass even though this function guarantees I was trying to make this test not affect total test runtime by adding another worker, since it's just sleeping for a while. But it didn't work yet (also, I fear having an extra worker might slow down tests a bit due to more process switching). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's just keep it simple rather than trying to work against xdist. If it's hard to figure out now I'm sure it will be hard to maintain in the future. Just in case, my go-to xdist wizard is @gnprice . |
||
try: | ||
os.replace(src, dest) | ||
except PermissionError: | ||
# The actual total wait might be up to 2x larger than timeout parameter | ||
if wait > timeout: | ||
raise | ||
else: | ||
return | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can move this |
||
time.sleep(wait) | ||
wait *= 2 | ||
|
||
|
||
if sys.platform.startswith("win"): | ||
replace = _replace | ||
else: | ||
replace = os.replace |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wow, turns out
mypy.test
,unittest
, and pytest are currently being used...ouch...There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, but Max didn't start that. IIUC we want to kill mypy.test, but it's low priority (not sure if there's even an issue) and pytest works well enough with unittest (also, personally, I'm more used to the unittest way of writing tests -- let pytest just be the test runner).