Skip to content

Commit 58cd490

Browse files
committed
Fixed #4948, a race condition in file saving. Thanks to Martin von Löwis, who diagnosed the problem and pointed the way to a fix.
git-svn-id: http://code.djangoproject.com/svn/django/trunk@8306 bcc190cf-cafb-0310-a4f2-bffc1f526a37
1 parent ab1a442 commit 58cd490

File tree

4 files changed

+92
-30
lines changed

4 files changed

+92
-30
lines changed

django/core/files/locks.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,24 @@
4040
except (ImportError, AttributeError):
4141
pass
4242

43+
def fd(f):
44+
"""Get a filedescriptor from something which could be a file or an fd."""
45+
return hasattr(f, 'fileno') and f.fileno() or f
46+
4347
if system_type == 'nt':
4448
def lock(file, flags):
45-
hfile = win32file._get_osfhandle(file.fileno())
49+
hfile = win32file._get_osfhandle(fd(file))
4650
win32file.LockFileEx(hfile, flags, 0, -0x10000, __overlapped)
4751

4852
def unlock(file):
49-
hfile = win32file._get_osfhandle(file.fileno())
53+
hfile = win32file._get_osfhandle(fd(file))
5054
win32file.UnlockFileEx(hfile, 0, -0x10000, __overlapped)
5155
elif system_type == 'posix':
5256
def lock(file, flags):
53-
fcntl.flock(file.fileno(), flags)
57+
fcntl.flock(fd(file), flags)
5458

5559
def unlock(file):
56-
fcntl.flock(file.fileno(), fcntl.LOCK_UN)
60+
fcntl.flock(fd(file), fcntl.LOCK_UN)
5761
else:
5862
# File locking is not supported.
5963
LOCK_EX = LOCK_SH = LOCK_NB = None

django/core/files/move.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,17 @@ def file_move_safe(old_file_name, new_file_name, chunk_size = 1024*64, allow_ove
4444
pass
4545

4646
# If the built-in didn't work, do it the hard way.
47-
new_file = open(new_file_name, 'wb')
48-
locks.lock(new_file, locks.LOCK_EX)
49-
old_file = open(old_file_name, 'rb')
50-
current_chunk = None
51-
52-
while current_chunk != '':
53-
current_chunk = old_file.read(chunk_size)
54-
new_file.write(current_chunk)
55-
56-
new_file.close()
57-
old_file.close()
47+
fd = os.open(new_file_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_BINARY', 0))
48+
try:
49+
locks.lock(fd, locks.LOCK_EX)
50+
old_file = open(old_file_name, 'rb')
51+
current_chunk = None
52+
while current_chunk != '':
53+
current_chunk = old_file.read(chunk_size)
54+
os.write(fd, current_chunk)
55+
finally:
56+
locks.unlock(fd)
57+
os.close(fd)
58+
old_file.close()
5859

5960
os.remove(old_file_name)

django/core/files/storage.py

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ def save(self, name, content):
3939
# Get the proper name for the file, as it will actually be saved.
4040
if name is None:
4141
name = content.name
42+
4243
name = self.get_available_name(name)
43-
44-
self._save(name, content)
44+
name = self._save(name, content)
4545

4646
# Store filenames with forward slashes, even on Windows
4747
return force_unicode(name.replace('\\', '/'))
@@ -135,19 +135,41 @@ def _save(self, name, content):
135135
os.makedirs(directory)
136136
elif not os.path.isdir(directory):
137137
raise IOError("%s exists and is not a directory." % directory)
138-
139-
if hasattr(content, 'temporary_file_path'):
140-
# This file has a file path that we can move.
141-
file_move_safe(content.temporary_file_path(), full_path)
142-
content.close()
143-
else:
144-
# This is a normal uploadedfile that we can stream.
145-
fp = open(full_path, 'wb')
146-
locks.lock(fp, locks.LOCK_EX)
147-
for chunk in content.chunks():
148-
fp.write(chunk)
149-
locks.unlock(fp)
150-
fp.close()
138+
139+
# There's a potential race condition between get_available_name and
140+
# saving the file; it's possible that two threads might return the
141+
# same name, at which point all sorts of fun happens. So we need to
142+
# try to create the file, but if it already exists we have to go back
143+
# to get_available_name() and try again.
144+
145+
while True:
146+
try:
147+
# This file has a file path that we can move.
148+
if hasattr(content, 'temporary_file_path'):
149+
file_move_safe(content.temporary_file_path(), full_path)
150+
content.close()
151+
152+
# This is a normal uploadedfile that we can stream.
153+
else:
154+
# This fun binary flag incantation makes os.open throw an
155+
# OSError if the file already exists before we open it.
156+
fd = os.open(full_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_BINARY', 0))
157+
try:
158+
locks.lock(fd, locks.LOCK_EX)
159+
for chunk in content.chunks():
160+
os.write(fd, chunk)
161+
finally:
162+
locks.unlock(fd)
163+
os.close(fd)
164+
except OSError:
165+
# Ooops, we need a new file name.
166+
name = self.get_available_name(name)
167+
full_path = self.path(name)
168+
else:
169+
# OK, the file save worked. Break out of the loop.
170+
break
171+
172+
return name
151173

152174
def delete(self, name):
153175
name = self.path(name)

tests/regressiontests/file_storage/tests.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,38 @@
6464
>>> custom_storage.delete(first)
6565
>>> custom_storage.delete(second)
6666
"""
67+
68+
# Tests for a race condition on file saving (#4948).
69+
# This is written in such a way that it'll always pass on platforms
70+
# without threading.
71+
72+
import time
73+
from unittest import TestCase
74+
from django.core.files.base import ContentFile
75+
from models import temp_storage
76+
try:
77+
import threading
78+
except ImportError:
79+
import dummy_threading as threading
80+
81+
class SlowFile(ContentFile):
82+
def chunks(self):
83+
time.sleep(1)
84+
return super(ContentFile, self).chunks()
85+
86+
class FileSaveRaceConditionTest(TestCase):
87+
def setUp(self):
88+
self.thread = threading.Thread(target=self.save_file, args=['conflict'])
89+
90+
def save_file(self, name):
91+
name = temp_storage.save(name, SlowFile("Data"))
92+
93+
def test_race_condition(self):
94+
self.thread.start()
95+
name = self.save_file('conflict')
96+
self.thread.join()
97+
self.assert_(temp_storage.exists('conflict'))
98+
self.assert_(temp_storage.exists('conflict_'))
99+
temp_storage.delete('conflict')
100+
temp_storage.delete('conflict_')
101+

0 commit comments

Comments
 (0)