# HG changeset patch # User Greg Ward # Date 2011-03-20 21:41:09 # Node ID a464763e99f106774d03064ebcb251e75f302b8b # Parent 48d606d7192b6f8f4fc8071fccdc5a83843ab2d3 dirstate: avoid a race with multiple commits in the same process (issue2264, issue2516) The race happens when two commits in a row change the same file without changing its size, *if* those two commits happen in the same second in the same process while holding the same repo lock. For example: commit 1: M a M b commit 2: # same process, same second, same repo lock M b # modify b without changing its size M c This first manifested in transplant, which is the most common way to do multiple commits in the same process. But it can manifest in any script or extension that does multiple commits under the same repo lock. (Thus, the test script tests both transplant and a custom script.) The problem was that dirstate.status() failed to notice the change to b when localrepo is about to do the second commit, meaning that change gets left in the working directory. In the context of transplant, that means either a crash ("RuntimeError: nothing committed after transplant") or a silently inaccurate transplant, depending on whether any other files were modified by the second transplanted changeset. The fix is to make status() work a little harder when we have previously marked files as clean (state 'normal') in the same process. Specifically, dirstate.normal() adds files to self._lastnormal, and other state-changing methods remove them. Then dirstate.status() puts any files in self._lastnormal into state 'lookup', which will make localrepository.status() read file contents to see if it has really changed. So we pay a small performance penalty for the second (and subsequent) commits in the same process, without affecting the common case. Anything that does lots of status updates and checks in the same process could suffer a performance hit. Incidentally, there is a simpler fix: call dirstate.normallookup() on every file updated by commit() at the end of the commit. The trouble with that solution is that it imposes a performance penalty on the common case: it means the next status-dependent hg command after every "hg commit" will be a little bit slower. The patch here is more complex, but only affects performance for the uncommon case. diff --git a/mercurial/dirstate.py b/mercurial/dirstate.py --- a/mercurial/dirstate.py +++ b/mercurial/dirstate.py @@ -49,6 +49,7 @@ class dirstate(object): self._rootdir = os.path.join(root, '') self._dirty = False self._dirtypl = False + self._lastnormal = set() # files believed to be normal self._ui = ui @propertycache @@ -285,6 +286,12 @@ class dirstate(object): if f in self._copymap: del self._copymap[f] + # Right now, this file is clean: but if some code in this + # process modifies it without changing its size before the clock + # ticks over to the next second, then it won't be clean anymore. + # So make sure that status() will look harder at it. + self._lastnormal.add(f) + def normallookup(self, f): '''Mark a file normal, but possibly dirty.''' if self._pl[1] != nullid and f in self._map: @@ -308,6 +315,7 @@ class dirstate(object): self._map[f] = ('n', 0, -1, -1) if f in self._copymap: del self._copymap[f] + self._lastnormal.discard(f) def otherparent(self, f): '''Mark as coming from the other parent, always dirty.''' @@ -319,6 +327,7 @@ class dirstate(object): self._map[f] = ('n', 0, -2, -1) if f in self._copymap: del self._copymap[f] + self._lastnormal.discard(f) def add(self, f): '''Mark a file added.''' @@ -327,6 +336,7 @@ class dirstate(object): self._map[f] = ('a', 0, -1, -1) if f in self._copymap: del self._copymap[f] + self._lastnormal.discard(f) def remove(self, f): '''Mark a file removed.''' @@ -343,6 +353,7 @@ class dirstate(object): self._map[f] = ('r', 0, size, 0) if size == 0 and f in self._copymap: del self._copymap[f] + self._lastnormal.discard(f) def merge(self, f): '''Mark a file merged.''' @@ -352,6 +363,7 @@ class dirstate(object): self._map[f] = ('m', s.st_mode, s.st_size, int(s.st_mtime)) if f in self._copymap: del self._copymap[f] + self._lastnormal.discard(f) def forget(self, f): '''Forget a file.''' @@ -361,6 +373,7 @@ class dirstate(object): del self._map[f] except KeyError: self._ui.warn(_("not in dirstate: %s\n") % f) + self._lastnormal.discard(f) def _normalize(self, path, knownpath): norm_path = os.path.normcase(path) @@ -640,6 +653,7 @@ class dirstate(object): radd = removed.append dadd = deleted.append cadd = clean.append + lastnormal = self._lastnormal.__contains__ lnkkind = stat.S_IFLNK @@ -672,6 +686,18 @@ class dirstate(object): elif (time != int(st.st_mtime) and (mode & lnkkind != lnkkind or self._checklink)): ladd(fn) + elif lastnormal(fn): + # If previously in this process we recorded that + # this file is clean, think twice: intervening code + # may have modified the file in the same second + # without changing its size. So force caller to + # check file contents. Because we're not updating + # self._map, this only affects the current process. + # That should be OK because this mainly affects + # multiple commits in the same process, and each + # commit by definition makes the committed files + # clean. + ladd(fn) elif listclean: cadd(fn) elif state == 'm': diff --git a/tests/test-commit-multiple.t b/tests/test-commit-multiple.t new file mode 100644 --- /dev/null +++ b/tests/test-commit-multiple.t @@ -0,0 +1,119 @@ +# reproduce issue2264, issue2516 + +create test repo + $ cat <> $HGRCPATH + > [extensions] + > transplant = + > graphlog = + > EOF + $ hg init repo + $ cd repo + $ template="{rev} {desc|firstline} [{branch}]\n" + +# we need to start out with two changesets on the default branch +# in order to avoid the cute little optimization where transplant +# pulls rather than transplants +add initial changesets + $ echo feature1 > file1 + $ hg ci -Am"feature 1" + adding file1 + $ echo feature2 >> file2 + $ hg ci -Am"feature 2" + adding file2 + +# The changes to 'bugfix' are enough to show the bug: in fact, with only +# those changes, it's a very noisy crash ("RuntimeError: nothing +# committed after transplant"). But if we modify a second file in the +# transplanted changesets, the bug is much more subtle: transplant +# silently drops the second change to 'bugfix' on the floor, and we only +# see it when we run 'hg status' after transplanting. Subtle data loss +# bugs are worse than crashes, so reproduce the subtle case here. +commit bug fixes on bug fix branch + $ hg branch fixes + marked working directory as branch fixes + $ echo fix1 > bugfix + $ echo fix1 >> file1 + $ hg ci -Am"fix 1" + adding bugfix + $ echo fix2 > bugfix + $ echo fix2 >> file1 + $ hg ci -Am"fix 2" + $ hg glog --template="$template" + @ 3 fix 2 [fixes] + | + o 2 fix 1 [fixes] + | + o 1 feature 2 [default] + | + o 0 feature 1 [default] + +transplant bug fixes onto release branch + $ hg update 0 + 1 files updated, 0 files merged, 2 files removed, 0 files unresolved + $ hg branch release + marked working directory as branch release + $ hg transplant 2 3 + applying [0-9a-f]{12} (re) + [0-9a-f]{12} transplanted to [0-9a-f]{12} (re) + applying [0-9a-f]{12} (re) + [0-9a-f]{12} transplanted to [0-9a-f]{12} (re) + $ hg glog --template="$template" + @ 5 fix 2 [release] + | + o 4 fix 1 [release] + | + | o 3 fix 2 [fixes] + | | + | o 2 fix 1 [fixes] + | | + | o 1 feature 2 [default] + |/ + o 0 feature 1 [default] + + $ hg status + $ hg status --rev 0:4 + M file1 + A bugfix + $ hg status --rev 4:5 + M bugfix + M file1 + +now test that we fixed the bug for all scripts/extensions + $ cat > $TESTTMP/committwice.py <<__EOF__ + > from mercurial import ui, hg, match, node + > + > def replacebyte(fn, b): + > f = open("file1", "rb+") + > f.seek(0, 0) + > f.write(b) + > f.close() + > + > repo = hg.repository(ui.ui(), '.') + > assert len(repo) == 6, \ + > "initial: len(repo) == %d, expected 6" % len(repo) + > try: + > wlock = repo.wlock() + > lock = repo.lock() + > m = match.exact(repo.root, '', ['file1']) + > replacebyte("file1", "x") + > n = repo.commit(text="x", user="test", date=(0, 0), match=m) + > print "commit 1: len(repo) == %d" % len(repo) + > replacebyte("file1", "y") + > n = repo.commit(text="y", user="test", date=(0, 0), match=m) + > print "commit 2: len(repo) == %d" % len(repo) + > finally: + > lock.release() + > wlock.release() + > __EOF__ + $ $PYTHON $TESTTMP/committwice.py + commit 1: len(repo) == 7 + commit 2: len(repo) == 8 + +Do a size-preserving modification outside of that process + $ echo abcd > bugfix + $ hg status + M bugfix + $ hg log --template "{rev} {desc} {files}\n" -r5: + 5 fix 2 bugfix file1 + 6 x file1 + 7 y file1