Skip to content

Commit 9348219

Browse files
committed
Harden RootSite integration test cleanup
1 parent fa8341a commit 9348219

1 file changed

Lines changed: 248 additions & 48 deletions

File tree

Lines changed: 248 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
using System;
22
using System.IO;
3+
using System.Threading;
34
using NUnit.Framework;
4-
using SIL.FieldWorks.FwCoreDlgs;
55
using SIL.FieldWorks.Common.FwUtils;
6+
using SIL.FieldWorks.FwCoreDlgs;
67
using SIL.LCModel;
78
using SIL.LCModel.Core.KernelInterfaces;
89
using SIL.LCModel.Infrastructure;
@@ -19,66 +20,91 @@ namespace SIL.FieldWorks.Common.RootSites.RootSiteTests
1920
public abstract class RealDataTestsBase
2021
{
2122
private const string ReusableProjectName = "integration_test_data";
23+
private const string ProjectMutexName = @"Local\FieldWorks.RealDataTests.integration_test_data";
24+
private const string TestProjectSentinelFileName = ".fieldworks-real-data-test-project";
25+
private const int DeleteRetryCount = 3;
26+
private static readonly TimeSpan DeleteRetryDelay = TimeSpan.FromMilliseconds(250);
2227

2328
protected FwNewLangProjectModel m_model;
2429
protected LcmCache Cache;
2530
protected string m_dbName;
2631
private string m_projectDirectory;
32+
private Mutex m_projectMutex;
2733

2834
[SetUp]
2935
public virtual void TestSetup()
3036
{
3137
m_dbName = ReusableProjectName;
3238
m_projectDirectory = DbDirectory(m_dbName);
33-
DeleteProjectDirectory(m_projectDirectory);
39+
AcquireProjectMutex();
3440

35-
// Init New Lang Project Model (headless)
36-
m_model = new FwNewLangProjectModel(true)
37-
{
38-
LoadProjectNameSetup = () => { },
39-
LoadVernacularSetup = () => { },
40-
LoadAnalysisSetup = () => { },
41-
AnthroModel = new FwChooseAnthroListModel { CurrentList = FwChooseAnthroListModel.ListChoice.UserDef }
42-
};
43-
44-
string createdPath;
45-
using (var threadHelper = new ThreadHelper())
41+
try
4642
{
47-
m_model.ProjectName = m_dbName;
48-
m_model.Next(); // To Vernacular WS Setup
49-
m_model.SetDefaultWs(new LanguageInfo { LanguageTag = "qaa", DesiredName = "Vernacular" });
50-
m_model.Next(); // To Analysis WS Setup
51-
m_model.SetDefaultWs(new LanguageInfo { LanguageTag = "en", DesiredName = "English" });
52-
createdPath = m_model.CreateNewLangProj(new DummyProgressDlg(), threadHelper);
53-
m_projectDirectory = Path.GetDirectoryName(createdPath);
54-
}
43+
DeleteProjectDirectory(m_projectDirectory);
44+
45+
m_model = new FwNewLangProjectModel(true)
46+
{
47+
LoadProjectNameSetup = () => { },
48+
LoadVernacularSetup = () => { },
49+
LoadAnalysisSetup = () => { },
50+
AnthroModel = new FwChooseAnthroListModel
51+
{
52+
CurrentList = FwChooseAnthroListModel.ListChoice.UserDef,
53+
},
54+
};
55+
56+
string createdPath;
57+
using (var threadHelper = new ThreadHelper())
58+
{
59+
m_model.ProjectName = m_dbName;
60+
m_model.Next(); // To Vernacular WS Setup
61+
m_model.SetDefaultWs(
62+
new LanguageInfo { LanguageTag = "qaa", DesiredName = "Vernacular" }
63+
);
64+
m_model.Next(); // To Analysis WS Setup
65+
m_model.SetDefaultWs(
66+
new LanguageInfo { LanguageTag = "en", DesiredName = "English" }
67+
);
68+
createdPath = m_model.CreateNewLangProj(new DummyProgressDlg(), threadHelper);
69+
m_projectDirectory = GetProjectDirectory(createdPath);
70+
WriteTestProjectSentinel(m_projectDirectory);
71+
}
5572

56-
// Load the cache from the newly created .fwdata file
57-
Cache = LcmCache.CreateCacheFromExistingData(
58-
new TestProjectId(BackendProviderType.kXMLWithMemoryOnlyWsMgr, createdPath),
59-
"en",
60-
new DummyLcmUI(),
61-
FwDirectoryFinder.LcmDirectories,
62-
new LcmSettings(),
63-
new DummyProgressDlg());
73+
Cache = LcmCache.CreateCacheFromExistingData(
74+
new TestProjectId(BackendProviderType.kXMLWithMemoryOnlyWsMgr, createdPath),
75+
"en",
76+
new DummyLcmUI(),
77+
FwDirectoryFinder.LcmDirectories,
78+
new LcmSettings(),
79+
new DummyProgressDlg()
80+
);
6481

65-
try
66-
{
67-
using (var undoWatcher = new UndoableUnitOfWorkHelper(Cache.ActionHandlerAccessor, "Test Setup", "Undo Test Setup"))
82+
try
6883
{
69-
InitializeProjectData();
70-
CreateTestData();
71-
undoWatcher.RollBack = false;
84+
using (
85+
var undoWatcher = new UndoableUnitOfWorkHelper(
86+
Cache.ActionHandlerAccessor,
87+
"Test Setup",
88+
"Undo Test Setup"
89+
)
90+
)
91+
{
92+
InitializeProjectData();
93+
CreateTestData();
94+
undoWatcher.RollBack = false;
95+
}
96+
}
97+
catch (Exception)
98+
{
99+
DisposeCache();
100+
throw;
72101
}
73102
}
74103
catch (Exception)
75104
{
76-
// If setup fails, ensure we don't leave a locked DB
77-
if (Cache != null)
78-
{
79-
Cache.Dispose();
80-
Cache = null;
81-
}
105+
DisposeCache();
106+
TryDeleteProjectDirectoryAfterSetupFailure();
107+
ReleaseProjectMutex();
82108
throw;
83109
}
84110
}
@@ -96,27 +122,201 @@ protected virtual void CreateTestData()
96122
[TearDown]
97123
public virtual void TestTearDown()
98124
{
99-
if (Cache != null)
125+
try
100126
{
101-
Cache.Dispose();
102-
Cache = null;
127+
DisposeCache();
128+
DeleteProjectDirectory(m_projectDirectory);
129+
}
130+
finally
131+
{
132+
m_projectDirectory = null;
133+
ReleaseProjectMutex();
103134
}
104-
105-
DeleteProjectDirectory(m_projectDirectory);
106-
m_projectDirectory = null;
107135
}
108136

109137
protected string DbDirectory(string name)
110138
{
111139
return Path.Combine(FwDirectoryFinder.ProjectsDirectory, name);
112140
}
113141

142+
private void AcquireProjectMutex()
143+
{
144+
m_projectMutex = new Mutex(false, ProjectMutexName);
145+
try
146+
{
147+
m_projectMutex.WaitOne();
148+
}
149+
catch (AbandonedMutexException)
150+
{
151+
}
152+
}
153+
154+
private void ReleaseProjectMutex()
155+
{
156+
if (m_projectMutex == null)
157+
return;
158+
159+
try
160+
{
161+
m_projectMutex.ReleaseMutex();
162+
}
163+
catch (ApplicationException)
164+
{
165+
}
166+
finally
167+
{
168+
m_projectMutex.Dispose();
169+
m_projectMutex = null;
170+
}
171+
}
172+
173+
private void DisposeCache()
174+
{
175+
if (Cache == null)
176+
return;
177+
178+
Cache.Dispose();
179+
Cache = null;
180+
}
181+
182+
private void TryDeleteProjectDirectoryAfterSetupFailure()
183+
{
184+
try
185+
{
186+
DeleteProjectDirectory(m_projectDirectory);
187+
}
188+
catch (Exception e)
189+
{
190+
TestContext.Error.WriteLine(
191+
"Could not clean up test project directory '{0}' after setup failure: {1}",
192+
m_projectDirectory,
193+
e.Message
194+
);
195+
}
196+
}
197+
198+
private static string GetProjectDirectory(string createdPath)
199+
{
200+
if (string.IsNullOrEmpty(createdPath))
201+
throw new InvalidOperationException("CreateNewLangProj did not return a project path.");
202+
203+
var fullPath = NormalizePath(createdPath);
204+
if (Directory.Exists(fullPath))
205+
{
206+
EnsureSafeProjectDirectory(fullPath);
207+
return fullPath;
208+
}
209+
210+
if (!File.Exists(fullPath))
211+
throw new FileNotFoundException("CreateNewLangProj returned a path that does not exist.", fullPath);
212+
213+
var projectDirectory = Path.GetDirectoryName(fullPath);
214+
EnsureSafeProjectDirectory(projectDirectory);
215+
return projectDirectory;
216+
}
217+
218+
private static void WriteTestProjectSentinel(string projectDirectory)
219+
{
220+
EnsureSafeProjectDirectory(projectDirectory);
221+
File.WriteAllText(
222+
GetSentinelFilePath(projectDirectory),
223+
"Created by FieldWorks RootSiteTests. This directory is safe for tests to delete."
224+
);
225+
}
226+
114227
private static void DeleteProjectDirectory(string projectDirectory)
115228
{
116229
if (string.IsNullOrEmpty(projectDirectory) || !Directory.Exists(projectDirectory))
117230
return;
118231

119-
try { Directory.Delete(projectDirectory, true); } catch { }
232+
var safeProjectDirectory = NormalizePath(projectDirectory);
233+
EnsureSafeProjectDirectory(safeProjectDirectory);
234+
235+
if (!File.Exists(GetSentinelFilePath(safeProjectDirectory)))
236+
{
237+
throw new InvalidOperationException(
238+
string.Format(
239+
"Refusing to delete '{0}' because the test sentinel file '{1}' is missing.",
240+
safeProjectDirectory,
241+
TestProjectSentinelFileName
242+
)
243+
);
244+
}
245+
246+
Exception lastException = null;
247+
for (var attempt = 1; attempt <= DeleteRetryCount; attempt++)
248+
{
249+
try
250+
{
251+
Directory.Delete(safeProjectDirectory, true);
252+
return;
253+
}
254+
catch (IOException e)
255+
{
256+
lastException = e;
257+
LogDeleteFailure(safeProjectDirectory, attempt, e);
258+
}
259+
catch (UnauthorizedAccessException e)
260+
{
261+
lastException = e;
262+
LogDeleteFailure(safeProjectDirectory, attempt, e);
263+
}
264+
265+
if (attempt < DeleteRetryCount)
266+
Thread.Sleep(DeleteRetryDelay);
267+
}
268+
269+
throw new IOException(
270+
string.Format(
271+
"Could not delete test project directory '{0}' after {1} attempts.",
272+
safeProjectDirectory,
273+
DeleteRetryCount
274+
),
275+
lastException
276+
);
277+
}
278+
279+
private static void LogDeleteFailure(string projectDirectory, int attempt, Exception e)
280+
{
281+
TestContext.Error.WriteLine(
282+
"Could not delete test project directory '{0}' on attempt {1} of {2}: {3}",
283+
projectDirectory,
284+
attempt,
285+
DeleteRetryCount,
286+
e.Message
287+
);
288+
}
289+
290+
private static void EnsureSafeProjectDirectory(string projectDirectory)
291+
{
292+
if (string.IsNullOrEmpty(projectDirectory))
293+
throw new InvalidOperationException("The test project directory path is empty.");
294+
295+
var safeProjectDirectory = NormalizePath(projectDirectory);
296+
var expectedProjectDirectory = NormalizePath(
297+
Path.Combine(FwDirectoryFinder.ProjectsDirectory, ReusableProjectName)
298+
);
299+
300+
if (!string.Equals(safeProjectDirectory, expectedProjectDirectory, StringComparison.OrdinalIgnoreCase))
301+
{
302+
throw new InvalidOperationException(
303+
string.Format(
304+
"Refusing to use test project directory '{0}'; expected '{1}'.",
305+
safeProjectDirectory,
306+
expectedProjectDirectory
307+
)
308+
);
309+
}
310+
}
311+
312+
private static string GetSentinelFilePath(string projectDirectory)
313+
{
314+
return Path.Combine(projectDirectory, TestProjectSentinelFileName);
315+
}
316+
317+
private static string NormalizePath(string path)
318+
{
319+
return Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
120320
}
121321
}
122322
}

0 commit comments

Comments
 (0)