Skip to content

Commit 8a30c53

Browse files
[release/3.1] Single file: Guard against partial cleanup of extracted files
** Issue dotnet/runtime#3778 ** Customer Scenario Single-file apps rely on extractiong their contents to disk. The extaction is performed to a temp-directory by default, which may be cleaned up by the OS. The app can re-extract itself if the entire extraction is cleaned up -- but not partial deletions. Customers have noticed that for some apps, the extracted files were removed, but the extraction directory itself wasn't. This causes the app to fail to start. ** Problem When executing single-file apps, if a pre-existing extraction exists, it is assumed to be valid. ** Solution When executing single-file apps, if a pre-existing extraction exists, the contents of the extraction directory are now verified. If files are missing, they are recovered. **Extraction Algorithm** `ExtractionDir` = `$DOTNET_BUNDLE_EXTRACT_BASE_DIR/<app>/<bundle-id>` `WorkingDir` = `$DOTNET_BUNDLE_EXTRACT_BASE_DIR/<app>/<process-id>` If `ExtractionDir` does not exist, then Create `WorkingDir`, and extract bundle contents within it Attempt to rename `WorkingDir` as `ExtractionDir` If the rename succeeds, continue execution of the app If not, another process completed extraction; Verify and reuse this extraction (as in step 2). If `ExtractionDir` exists, then verify that it contains all files listed in the manifest. If all contents are available, Remove `WorkingDir` Continue execution of the app, reusing the contents. If certain files are missing within `ExtractionDir`, then For each missing file, do the following individually Extract the files within `WorkingDir` Create sub-paths within `ExtractionDir` if necessary Rename the file from `WorkingDir/path/<file>` to `ExtractionDir/path/<file>` unless `ExtractionDir/path/<file>` exists (extracted there by another process in the meantime) Remove `WorkingDir` Continue execution of the app. All of the renames above are done with appropriate retries to circumvent interference from anti-virus apps. ** Startup time impact * Console HelloWorld execution time: * Framework dependent app: Windows/Linux No measurable difference * Self-contained app: Windows: ~10ms additional * Self-contained app: Linux: No measurable difference * Greenshot Startup: * Self-contained Windows: No noticiable/measurable difference * NugetPackageExplorer Startup: * Self-contained Windows: No noticiable/measurable difference ** Risk Medium. The change is well scoped, but isn't trivial. It affects all single-file apps. ** Master Branch dotnet/runtime#32649
1 parent 2d163b8 commit 8a30c53

File tree

3 files changed

+206
-50
lines changed

3 files changed

+206
-50
lines changed

src/corehost/cli/apphost/bundle/bundle_runner.cpp

+152-49
Original file line numberDiff line numberDiff line change
@@ -135,17 +135,23 @@ static void remove_directory_tree(const pal::string_t& path)
135135

136136
for (const pal::string_t &dir : dirs)
137137
{
138-
remove_directory_tree(dir);
138+
pal::string_t dir_path = path;
139+
append_path(&dir_path, dir.c_str());
140+
141+
remove_directory_tree(dir_path);
139142
}
140143

141144
std::vector<pal::string_t> files;
142145
pal::readdir(path, &files);
143146

144147
for (const pal::string_t &file : files)
145148
{
146-
if (!pal::remove(file.c_str()))
149+
pal::string_t file_path = path;
150+
append_path(&file_path, file.c_str());
151+
152+
if (!pal::remove(file_path.c_str()))
147153
{
148-
trace::warning(_X("Failed to remove temporary file [%s]."), file.c_str());
154+
trace::warning(_X("Failed to remove temporary file [%s]."), file_path.c_str());
149155
}
150156
}
151157

@@ -155,6 +161,49 @@ static void remove_directory_tree(const pal::string_t& path)
155161
}
156162
}
157163

164+
// Retry the rename operation with some wait in between the attempts.
165+
// This is an attempt to workaround for possible file locking caused by AV software.
166+
167+
bool rename_with_retries(pal::string_t& old_name, pal::string_t& new_name, bool& file_exists)
168+
{
169+
for (int retry_count = 0; retry_count < 500; retry_count++)
170+
{
171+
if (pal::rename(old_name.c_str(), new_name.c_str()) == 0)
172+
{
173+
return true;
174+
}
175+
bool should_retry = errno == EACCES;
176+
177+
if (pal::file_exists(new_name))
178+
{
179+
// Check file_exists() on each run, because a concurrent process may have
180+
// created the new_name file/directory.
181+
//
182+
// The rename() operation above fails with errono == EACCESS if
183+
// * Directory new_name already exists, or
184+
// * Paths are invalid paths, or
185+
// * Due to locking/permission problems.
186+
// Therefore, we need to perform the directory_exists() check again.
187+
188+
file_exists = true;
189+
return false;
190+
}
191+
192+
if (should_retry)
193+
{
194+
trace::info(_X("Retrying Rename [%s] to [%s] due to EACCES error"), old_name.c_str(), new_name.c_str());
195+
pal::sleep(100);
196+
continue;
197+
}
198+
else
199+
{
200+
return false;
201+
}
202+
}
203+
204+
return false;
205+
}
206+
158207
void bundle_runner_t::reopen_host_for_reading()
159208
{
160209
m_bundle_stream = pal::file_open(m_bundle_path, _X("rb"));
@@ -254,18 +303,98 @@ void bundle_runner_t::extract_file(file_entry_t *entry)
254303
fclose(file);
255304
}
256305

257-
bool bundle_runner_t::can_reuse_extraction()
306+
void bundle_runner_t::commit_file(const pal::string_t& relative_path)
258307
{
259-
// In this version, the extracted files are assumed to be
260-
// correct by construction.
261-
//
262-
// Files embedded in the bundle are first extracted to m_working_extraction_dir
263-
// Once all files are successfully extracted, the extraction location is
264-
// committed (renamed) to m_extraction_dir. Therefore, the presence of
265-
// m_extraction_dir means that the files are pre-extracted.
308+
// Commit individual files to the final extraction directory.
309+
310+
pal::string_t working_file_path = m_working_extraction_dir;
311+
append_path(&working_file_path, relative_path.c_str());
266312

313+
pal::string_t final_file_path = m_extraction_dir;
314+
append_path(&final_file_path, relative_path.c_str());
267315

268-
return pal::directory_exists(m_extraction_dir);
316+
if (has_dirs_in_path(relative_path))
317+
{
318+
create_directory_tree(get_directory(final_file_path));
319+
}
320+
321+
bool extracted_by_concurrent_process = false;
322+
bool extracted_by_current_process =
323+
rename_with_retries(working_file_path, final_file_path, extracted_by_concurrent_process);
324+
325+
if (extracted_by_concurrent_process)
326+
{
327+
// Another process successfully extracted the dependencies
328+
trace::info(_X("Extraction completed by another process, aborting current extraction."));
329+
}
330+
331+
if (!extracted_by_current_process && !extracted_by_concurrent_process)
332+
{
333+
trace::error(_X("Failure processing application bundle."));
334+
trace::error(_X("Failed to commit extracted files to directory [%s]."), m_extraction_dir.c_str());
335+
throw StatusCode::BundleExtractionFailure;
336+
}
337+
338+
trace::info(_X("Extraction recovered [%s]"), relative_path.c_str());
339+
}
340+
341+
void bundle_runner_t::commit_dir()
342+
{
343+
// Commit an entire new extraction to the final extraction directory
344+
// Retry the move operation with some wait in between the attempts. This is to workaround for possible file locking
345+
// caused by AV software. Basically the extraction process above writes a bunch of executable files to disk
346+
// and some AV software may decide to scan them on write. If this happens the files will be locked which blocks
347+
// our ablity to move them.
348+
349+
bool extracted_by_concurrent_process = false;
350+
bool extracted_by_current_process =
351+
rename_with_retries(m_working_extraction_dir, m_extraction_dir, extracted_by_concurrent_process);
352+
353+
if (extracted_by_concurrent_process)
354+
{
355+
// Another process successfully extracted the dependencies
356+
trace::info(_X("Extraction completed by another process, aborting current extraction."));
357+
remove_directory_tree(m_working_extraction_dir);
358+
}
359+
360+
if (!extracted_by_current_process && !extracted_by_concurrent_process)
361+
{
362+
trace::error(_X("Failure processing application bundle."));
363+
trace::error(_X("Failed to commit extracted files to directory [%s]."), m_extraction_dir.c_str());
364+
throw StatusCode::BundleExtractionFailure;
365+
}
366+
367+
trace::info(_X("Completed new extraction."));
368+
}
369+
370+
// Verify that an existing extraction contains all files listed in the bundle manifest.
371+
// If some files are missing, extract them individually.
372+
void bundle_runner_t::verify_recover_extraction()
373+
{
374+
bool recovered = false;
375+
376+
for (file_entry_t* entry : m_manifest->files)
377+
{
378+
pal::string_t file_path = m_extraction_dir;
379+
append_path(&file_path, entry->relative_path().c_str());
380+
381+
if (!pal::file_exists(file_path))
382+
{
383+
if (!recovered)
384+
{
385+
recovered = true;
386+
create_working_extraction_dir();
387+
}
388+
389+
extract_file(entry);
390+
commit_file(entry->relative_path());
391+
}
392+
}
393+
394+
if (recovered)
395+
{
396+
remove_directory_tree(m_working_extraction_dir);
397+
}
269398
}
270399

271400
// Current support for executing single-file bundles involves
@@ -281,13 +410,22 @@ StatusCode bundle_runner_t::extract()
281410
seek(m_bundle_stream, marker_t::header_offset(), SEEK_SET);
282411
m_header.reset(header_t::read(m_bundle_stream));
283412

413+
m_manifest.reset(manifest_t::read(m_bundle_stream, num_embedded_files()));
414+
284415
// Determine if embedded files are already extracted, and available for reuse
285416
determine_extraction_dir();
286-
if (can_reuse_extraction())
417+
if (pal::directory_exists(m_extraction_dir))
287418
{
419+
// If so, verify the contents, recover files if necessary,
420+
// and use the existing extraction.
421+
422+
verify_recover_extraction();
423+
fclose(m_bundle_stream);
288424
return StatusCode::Success;
289425
}
290426

427+
// If not, create a new extraction
428+
291429
// Extract files to temporary working directory
292430
//
293431
// Files are extracted to a specific deterministic location on disk
@@ -307,46 +445,11 @@ StatusCode bundle_runner_t::extract()
307445

308446
create_working_extraction_dir();
309447

310-
m_manifest.reset(manifest_t::read(m_bundle_stream, num_embedded_files()));
311-
312448
for (file_entry_t* entry : m_manifest->files) {
313449
extract_file(entry);
314450
}
315451

316-
// Commit files to the final extraction directory
317-
// Retry the move operation with some wait in between the attempts. This is to workaround for possible file locking
318-
// caused by AV software. Basically the extraction process above writes a bunch of executable files to disk
319-
// and some AV software may decide to scan them on write. If this happens the files will be locked which blocks
320-
// our ablity to move them.
321-
int retry_count = 500;
322-
while (true)
323-
{
324-
if (pal::rename(m_working_extraction_dir.c_str(), m_extraction_dir.c_str()) == 0)
325-
break;
326-
327-
bool should_retry = errno == EACCES;
328-
if (can_reuse_extraction())
329-
{
330-
// Another process successfully extracted the dependencies
331-
trace::info(_X("Extraction completed by another process, aborting current extraction."));
332-
333-
remove_directory_tree(m_working_extraction_dir);
334-
break;
335-
}
336-
337-
if (should_retry && (retry_count--) > 0)
338-
{
339-
trace::info(_X("Retrying extraction due to EACCES trying to rename the extraction folder to [%s]."), m_extraction_dir.c_str());
340-
pal::sleep(100);
341-
continue;
342-
}
343-
else
344-
{
345-
trace::error(_X("Failure processing application bundle."));
346-
trace::error(_X("Failed to commit extracted files to directory [%s]"), m_extraction_dir.c_str());
347-
throw StatusCode::BundleExtractionFailure;
348-
}
349-
}
452+
commit_dir();
350453

351454
fclose(m_bundle_stream);
352455
return StatusCode::Success;

src/corehost/cli/apphost/bundle/bundle_runner.h

+3-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ namespace bundle
4545

4646
void determine_extraction_dir();
4747
void create_working_extraction_dir();
48-
bool can_reuse_extraction();
48+
void verify_recover_extraction();
49+
void commit_file(const pal::string_t& relative_path);
50+
void commit_dir();
4951

5052
FILE* create_extraction_file(const pal::string_t& relative_path);
5153
void extract_file(file_entry_t* entry);

src/test/BundleTests/AppHost.Bundle.Tests/BundleExtractToSpecificPath.cs

+51
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,57 @@ private void Bundle_extraction_is_reused()
118118
extractDir.Should().NotBeModifiedAfter(firstWriteTime);
119119
}
120120

121+
[Fact]
122+
private void Bundle_extraction_can_recover_missing_files()
123+
{
124+
var fixture = sharedTestState.TestFixture.Copy();
125+
var hostName = BundleHelper.GetHostName(fixture);
126+
var appName = Path.GetFileNameWithoutExtension(hostName);
127+
string publishPath = BundleHelper.GetPublishPath(fixture);
128+
129+
// Publish the bundle
130+
var bundleDir = BundleHelper.GetBundleDir(fixture);
131+
var bundler = new Microsoft.NET.HostModel.Bundle.Bundler(hostName, bundleDir.FullName);
132+
string singleFile = bundler.GenerateBundle(publishPath);
133+
134+
// Compute bundled files
135+
List<string> bundledFiles = bundler.BundleManifest.Files.Select(file => file.RelativePath).ToList();
136+
137+
// Create a directory for extraction.
138+
var extractBaseDir = BundleHelper.GetExtractDir(fixture);
139+
string extractPath = Path.Combine(extractBaseDir.FullName, appName, bundler.BundleManifest.BundleID);
140+
var extractDir = new DirectoryInfo(extractPath);
141+
142+
// Run the bunded app for the first time, and extract files to
143+
// $DOTNET_BUNDLE_EXTRACT_BASE_DIR/<app>/bundle-id
144+
Command.Create(singleFile)
145+
.CaptureStdErr()
146+
.CaptureStdOut()
147+
.EnvironmentVariable(BundleHelper.DotnetBundleExtractBaseEnvVariable, extractBaseDir.FullName)
148+
.Execute()
149+
.Should()
150+
.Pass()
151+
.And
152+
.HaveStdOutContaining("Hello World");
153+
154+
bundledFiles.ForEach(file => File.Delete(Path.Combine(extractPath, file)));
155+
156+
extractDir.Should().Exist();
157+
extractDir.Should().NotHaveFiles(bundledFiles);
158+
159+
// Run the bundled app again (recover deleted files)
160+
Command.Create(singleFile)
161+
.CaptureStdErr()
162+
.CaptureStdOut()
163+
.EnvironmentVariable(BundleHelper.DotnetBundleExtractBaseEnvVariable, extractBaseDir.FullName)
164+
.Execute()
165+
.Should()
166+
.Pass()
167+
.And
168+
.HaveStdOutContaining("Hello World");
169+
170+
extractDir.Should().HaveFiles(bundledFiles);
171+
}
121172

122173
public class SharedTestState : IDisposable
123174
{

0 commit comments

Comments
 (0)