From 601a06d0d6d6455c3a8f9e6170953cb63c873690 Mon Sep 17 00:00:00 2001 From: Paul Molodowitch Date: Tue, 18 Apr 2023 16:33:42 -0700 Subject: [PATCH] [tf] AtomicRenameUtil - add short retry period on windows On windows, things like virus checkers and indexing service can often grab a handle to newly-created files; they usually release them fairly quickly, though, so just need a short retry period Closes: https://github.com/PixarAnimationStudios/USD/issues/2141 --- pxr/base/arch/fileSystem.cpp | 19 +- pxr/base/arch/fileSystem.h | 1 + pxr/base/tf/CMakeLists.txt | 16 + pxr/base/tf/atomicRenameUtil.cpp | 84 ++++- pxr/base/tf/testenv/testAtomicRenameUtil.cpp | 140 ++++++++ pxr/base/tf/testenv/testTfAtomicRenameUtil.py | 317 ++++++++++++++++++ 6 files changed, 565 insertions(+), 12 deletions(-) create mode 100644 pxr/base/tf/testenv/testAtomicRenameUtil.cpp create mode 100644 pxr/base/tf/testenv/testTfAtomicRenameUtil.py diff --git a/pxr/base/arch/fileSystem.cpp b/pxr/base/arch/fileSystem.cpp index 50ce636cb3..aa3e29c1f3 100644 --- a/pxr/base/arch/fileSystem.cpp +++ b/pxr/base/arch/fileSystem.cpp @@ -1082,14 +1082,9 @@ static int Arch_FileAccessError() } } -int ArchFileAccess(const char* path, int mode) +int ArchWindowsFileAccess(const char* path, DWORD accessMask) { - // Simple existence check is handled specially. std::wstring wpath{ ArchWindowsUtf8ToUtf16(path) }; - if (mode == F_OK) { - return (GetFileAttributesW(wpath.c_str()) != INVALID_FILE_ATTRIBUTES) - ? 0 : Arch_FileAccessError(); - } const SECURITY_INFORMATION securityInfo = OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | @@ -1140,7 +1135,6 @@ int ArchFileAccess(const char* path, int mode) mapping.GenericExecute = FILE_GENERIC_EXECUTE; mapping.GenericAll = FILE_ALL_ACCESS; - DWORD accessMask = ArchModeToAccess(mode); MapGenericMask(&accessMask, &mapping); if (AccessCheck(security, @@ -1166,6 +1160,17 @@ int ArchFileAccess(const char* path, int mode) return result ? 0 : -1; } +int ArchFileAccess(const char* path, int mode) +{ + // Simple existence check is handled specially. + if (mode == F_OK) { + std::wstring wpath{ ArchWindowsUtf8ToUtf16(path) }; + return (GetFileAttributesW(wpath.c_str()) != INVALID_FILE_ATTRIBUTES) + ? 0 : Arch_FileAccessError(); + } + return ArchWindowsFileAccess(path, ArchModeToAccess(mode)); +} + // https://msdn.microsoft.com/en-us/library/windows/hardware/ff552012.aspx #define MAX_REPARSE_DATA_SIZE (16 * 1024) diff --git a/pxr/base/arch/fileSystem.h b/pxr/base/arch/fileSystem.h index 432d6d7063..7bb6c908ed 100644 --- a/pxr/base/arch/fileSystem.h +++ b/pxr/base/arch/fileSystem.h @@ -135,6 +135,7 @@ ArchOpenFile(char const* fileName, char const* mode); #endif #if defined(ARCH_OS_WINDOWS) + ARCH_API int ArchWindowsFileAccess(const char* path, DWORD accessMask); ARCH_API int ArchFileAccess(const char* path, int mode); #else # define ArchFileAccess(path, mode) access(path, mode) diff --git a/pxr/base/tf/CMakeLists.txt b/pxr/base/tf/CMakeLists.txt index b236184c47..18f9e17029 100644 --- a/pxr/base/tf/CMakeLists.txt +++ b/pxr/base/tf/CMakeLists.txt @@ -425,6 +425,15 @@ pxr_build_test_shared_lib(TestTfRegistryFunctionPlugin testenv/TestTfRegistryFunctionPlugin.cpp ) +if(WIN32) + pxr_build_test(testAtomicRenameUtil + LIBRARIES + tf + CPPFILES + testenv/testAtomicRenameUtil.cpp + ) +endif() + pxr_build_test(testTfCast LIBRARIES tf @@ -495,6 +504,7 @@ pxr_build_test(testTf ) pxr_test_scripts( + testenv/testTfAtomicRenameUtil.py testenv/testTfCrashHandler.py testenv/testTfFileUtils.py testenv/testTfMallocTagReport.py @@ -549,6 +559,12 @@ pxr_register_test(TfAnyUniquePtr pxr_register_test(TfAtomicOfstreamWrapper COMMAND "${CMAKE_INSTALL_PREFIX}/tests/testTf TfAtomicOfstreamWrapper" ) +if(WIN32) + pxr_register_test(TfAtomicRenameUtil + PYTHON + COMMAND "${CMAKE_INSTALL_PREFIX}/tests/testTfAtomicRenameUtil ${CMAKE_INSTALL_PREFIX}/tests/testAtomicRenameUtil.exe" + ) +endif() pxr_register_test(TfBitUtils COMMAND "${CMAKE_INSTALL_PREFIX}/tests/testTf TfBitUtils" ) diff --git a/pxr/base/tf/atomicRenameUtil.cpp b/pxr/base/tf/atomicRenameUtil.cpp index caa6e3d0e0..578b735285 100644 --- a/pxr/base/tf/atomicRenameUtil.cpp +++ b/pxr/base/tf/atomicRenameUtil.cpp @@ -17,16 +17,62 @@ #include "pxr/base/tf/envSetting.h" #if defined(ARCH_OS_WINDOWS) +#include "pxr/base/tf/envSetting.h" #include +#include #include +#include #endif #include #include +#if defined(ARCH_OS_WINDOWS) +namespace { + PXR_NAMESPACE_USING_DIRECTIVE + + bool TryMove(std::wstring const &wsrc, std::wstring const &wdst) { + return MoveFileExW(wsrc.c_str(), wdst.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED) != FALSE; + } + + bool HaveMovePermissions(std::string const &src, std::string const &dst) { + // Docs for MovFileExW say: + // To delete or rename a file, you must have either delete permission + // on the file or delete child permission in the parent directory. + // https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexa + + if (ArchWindowsFileAccess(src.c_str(), DELETE) != 0) { + // Don't have delete perms on file, check for FILE_DELETE_CHILD on parent dir + std::string srcParent = TfGetPathName(src); + if (ArchWindowsFileAccess(srcParent.c_str(), FILE_DELETE_CHILD) != 0) { + return false; + } + } + // presumably you need create child permission in the parent directory of dst + std::string dstParent = TfGetPathName(dst); + return ArchWindowsFileAccess(dstParent.c_str(), FILE_ADD_FILE) == 0; + } +} +#endif + + PXR_NAMESPACE_OPEN_SCOPE #if defined(ARCH_OS_WINDOWS) + + // On Windows, it's not uncommon for some external process to grab a handle to + // newly created files (ie, Anti-Virus, Windows File Indexing), which can make + // that file inaccessible, and make the move fail. The duration of the lock + // is usually brief, though, so add a short-ish retry period if it's locked. + + // By default, we wait ~.3 seconds before giving up + TF_DEFINE_ENV_SETTING(TF_FILE_LOCK_NUM_RETRIES, 15, + "Number of times to retry file renaming if a lock held"); + + TF_DEFINE_ENV_SETTING(TF_FILE_LOCK_RETRY_WAIT_MS, 20, + "Time in microseconds to wait between retries when lock held on renamed file"); + // Older networked filesystems have reported incorrect file permissions // on Windows so the write permissions check has been disabled as a default static const bool requireWritePermissionDefault = false; @@ -53,16 +99,44 @@ Tf_AtomicRenameFileOver(std::string const &srcFileName, #if defined(ARCH_OS_WINDOWS) const std::wstring wsrc{ ArchWindowsUtf8ToUtf16(srcFileName) }; const std::wstring wdst{ ArchWindowsUtf8ToUtf16(dstFileName) }; - bool moved = MoveFileExW(wsrc.c_str(), - wdst.c_str(), - MOVEFILE_REPLACE_EXISTING | - MOVEFILE_COPY_ALLOWED) != FALSE; + + // On Windows, it's not uncommon for some external process to grab a handle to + // newly created files (ie, Anti-Virus, Windows File Indexing), which can make + // that file inaccessible, and make the move fail. The duration of the lock + // is usually brief, though, so add a short-ish retry period if it's locked. + + static const int numRetries = std::max(TfGetEnvSetting(TF_FILE_LOCK_NUM_RETRIES), 0); + static const int waitMS = std::max(TfGetEnvSetting(TF_FILE_LOCK_RETRY_WAIT_MS), 0); + + bool moved = false; + DWORD lastError = 0; + + for (int i = 0; i <= numRetries; i++) { + moved = TryMove(wsrc, wdst); + if (moved) { + break; + } + lastError = ::GetLastError(); + // Only check file perms the first time as an optimization - it's a + // filesystem operation, and possibly slow + if (i == 0 && !HaveMovePermissions(srcFileName, dstFileName)) { + break; + } + if (lastError != ERROR_SHARING_VIOLATION + && lastError != ERROR_LOCK_VIOLATION + && lastError != ERROR_ACCESS_DENIED + ) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(waitMS)); + } + if (!moved) { *error = TfStringPrintf( "Failed to rename temporary file '%s' to '%s': %s", srcFileName.c_str(), dstFileName.c_str(), - ArchStrSysError(::GetLastError()).c_str()); + ArchStrSysError(lastError).c_str()); result = false; } #else diff --git a/pxr/base/tf/testenv/testAtomicRenameUtil.cpp b/pxr/base/tf/testenv/testAtomicRenameUtil.cpp new file mode 100644 index 0000000000..3e5f5e33c2 --- /dev/null +++ b/pxr/base/tf/testenv/testAtomicRenameUtil.cpp @@ -0,0 +1,140 @@ +// +// Copyright 2023 Pixar +// +// Licensed under the Apache License, Version 2.0 (the "Apache License") +// with the following modification; you may not use this file except in +// compliance with the Apache License and the following modification to it: +// Section 6. Trademarks. is deleted and replaced with: +// +// 6. Trademarks. This License does not grant permission to use the trade +// names, trademarks, service marks, or product names of the Licensor +// and its affiliates, except as required to comply with Section 4(c) of +// the License and to reproduce the content of the NOTICE file. +// +// You may obtain a copy of the Apache License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the Apache License with the above modification is +// distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the Apache License for the specific +// language governing permissions and limitations under the Apache License. +// + +#include "pxr/pxr.h" +#include "pxr/base/tf/errorMark.h" +#include "pxr/base/tf/fileUtils.h" +#include "pxr/base/tf/ostreamMethods.h" +#include "pxr/base/tf/pathUtils.h" +#include "pxr/base/tf/safeOutputFile.h" +#include "pxr/base/tf/stackTrace.h" +#include "pxr/base/tf/stringUtils.h" + +#include +#include +#include +#include +#include +#include + +using std::string; + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace { + constexpr int WRONG_NUMBER_ARGS = 2; + constexpr float MAX_WAIT_FOR_FILE_SECONDS = 10; + constexpr float WAIT_FOR_SLEEP_SECONDS = .1; + constexpr auto FINAL_EXT = ".final"; + + // We're testing using TfSafeOutputFile::Replace, which first writes to + // a temporary file, then moves it to the final file. + // In our tests, our final files end with a ".final" filename suffix. + // This checks for the existence of the temp files, by finding matches + // that DON'T have the ".final" suffix + static size_t + Tf_CountTempFileMatches(const string& pattern) + { + std::vector matches = TfGlob(pattern, 0); + + // Count matches that don't end with ".final" + return std::count_if( + matches.begin(), matches.end(), + [](const string& x) { + return !TfStringEndsWith(x, FINAL_EXT); + } + ); + } + +} // end annonymous namespace + +/// Tries to run a TfSafeOutputFile::Replace +/// +/// If a non-empty waitForFile is provided, then it will pause after the temp files +/// are created, but before the file move is made, until the waitForFile exists. +/// +/// This provides a means of communication for our external testing program, so +/// it can run arbitrary code at this point, then create the waitForFile to +/// signal that this process should proceed with the file move. +static void +RunSafeOutputFileReplace(const string& fileBaseName, const string& waitForFile) +{ + // We want to test Tf_AtomicRenameFileOver, but that's not exposed publicly, so we test + // TfSafeOutputFile::Replace, which uses it + + TfErrorMark tfErrors; + + string fileFinalName = fileBaseName + FINAL_EXT; + string fileTempPattern = fileBaseName + ".*"; + auto outf = TfSafeOutputFile::Replace(fileFinalName); + TF_AXIOM(outf.Get()); + TF_AXIOM(tfErrors.IsClean()); + + // Temporary file exists. + TF_AXIOM(Tf_CountTempFileMatches(fileTempPattern) == 1); + + // Write content to the stream. + fprintf(outf.Get(), "New Content\n"); + + // If a waitForFile was given, pause until that file exists + if (!waitForFile.empty()) { + auto start = std::chrono::steady_clock::now(); + auto maxTime = start + std::chrono::duration(MAX_WAIT_FOR_FILE_SECONDS); + auto sleepTime = std::chrono::duration(WAIT_FOR_SLEEP_SECONDS); + while(!TfPathExists(waitForFile)) { + TF_AXIOM(std::chrono::steady_clock::now() < maxTime); + std::this_thread::sleep_for(sleepTime); + } + } + + // Commit. + outf.Close(); + TF_AXIOM(!outf.Get()); + TF_AXIOM(tfErrors.IsClean()); + + // Temporary file is gone. + TF_AXIOM(Tf_CountTempFileMatches(fileTempPattern) == 0); + + // Verify destination file content. + std::ifstream ifs(fileFinalName); + string newContent; + getline(ifs, newContent); + TF_AXIOM(newContent == "New Content"); +} + +int +main(int argc, char *argv[]) +{ + if (argc < 2) { + string progName(argv[0]); + std::cerr << "Usage: " << progName << " FILE_BASE_NAME [WAIT_FOR_FILE]" << std::endl; + return WRONG_NUMBER_ARGS; + } + string waitForFile; + if (argc > 2) { + waitForFile = argv[2]; + } + RunSafeOutputFileReplace(argv[1], waitForFile); + return 0; +} diff --git a/pxr/base/tf/testenv/testTfAtomicRenameUtil.py b/pxr/base/tf/testenv/testTfAtomicRenameUtil.py new file mode 100644 index 0000000000..6a5abf77b3 --- /dev/null +++ b/pxr/base/tf/testenv/testTfAtomicRenameUtil.py @@ -0,0 +1,317 @@ +#!/pxrpythonsubst +# +# Copyright 2023 Pixar +# +# Licensed under the Apache License, Version 2.0 (the "Apache License") +# with the following modification; you may not use this file except in +# compliance with the Apache License and the following modification to it: +# Section 6. Trademarks. is deleted and replaced with: +# +# 6. Trademarks. This License does not grant permission to use the trade +# names, trademarks, service marks, or product names of the Licensor +# and its affiliates, except as required to comply with Section 4(c) of +# the License and to reproduce the content of the NOTICE file. +# +# You may obtain a copy of the Apache License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the Apache License with the above modification is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the Apache License for the specific +# language governing permissions and limitations under the Apache License. +# +import argparse +import glob +import math +import os +import platform +import shutil +import subprocess +import sys +import tempfile +import time +import unittest + +from typing import Callable, List, Optional + +TEST_FILE_BASE = "atomicRenameTestFile" +TEST_FILE_FINAL = TEST_FILE_BASE + ".final" +TEST_FILE_GLOB = TEST_FILE_BASE + ".*" +OLD_CONTENT = "The old stuff" +EXPECTED_NEW_CONTENT = "New Content" +SENTRY_FILENAME = "wait_for_me.txt" + +# We do a high number of retries, with a longish wait, to try to ensure success +NUM_RETRIES = 100 +RETRY_WAIT_SEC = 0.03 + +# this timeout should be long enough to ensure that we either run out of retries, or finish successfully +TIMEOUT = 5.0 + +testAtomicRenameUtil_exe_path = "" + + +if platform.system() == "Windows": + + def launch_testAtomicRenameUtil( + do_retry: bool, + retry_wait_seconds: float, + folder: str, + wait_for_file: str = "", + ) -> subprocess.Popen: + env = dict(os.environ) + env["TF_FILE_LOCK_NUM_RETRIES"] = str(NUM_RETRIES if do_retry else 0) + env["TF_FILE_LOCK_RETRY_WAIT_MS"] = str(int(retry_wait_seconds * 1000)) + args = [testAtomicRenameUtil_exe_path, os.path.join(folder, TEST_FILE_BASE)] + if wait_for_file: + args.append(wait_for_file) + return subprocess.Popen(args, env=env) + + def get_temp_files(folder: str) -> "List[str]": + all_matches = glob.glob(os.path.join(folder, TEST_FILE_GLOB)) + return [x for x in all_matches if x != os.path.join(folder, TEST_FILE_FINAL)] + + def folder_perms_deny(path: str): + subprocess.check_call(["icacls", path, "/deny", "EVERYONE:(DC,WD)"]) + + def folder_perms_reset(path: str): + subprocess.check_call(["icacls", path, "/reset", "/t", "/c"]) + + class TestAtomicRenameUtil(unittest.TestCase): + def __init__(self, *args, **kwargs): + self.retry_wait_seconds = RETRY_WAIT_SEC + # top level directory that we will clean up when done + self.tempdir = "" + # directory that will contain the test output file - should either + # be self.tempdir or a subdirectory + self.testingdir = "" + self.timer_start = -math.inf + super().__init__(*args, **kwargs) + + def assert_new_content(self): + with open(os.path.join(self.testingdir, TEST_FILE_FINAL), "r", encoding="utf8") as reader: + content = reader.read().strip() + self.assertEqual(content, EXPECTED_NEW_CONTENT) + + def _assert_testAtomicRenameUtil( + self, + do_retry: bool, + expect_success: bool, + wait_for_file: str = "", + post_launch_callback: Optional[Callable[[subprocess.Popen], None]] = None, + ) -> bool: + """ + Run our testAtomicRenameUtil test executable, to verify correct operation of + TfSafeOutputFile::Replace / TfAtomicRenameUtil + + Can optionally provide a wait_for_file to signal testAtomicRenameUtil to + pause after creating temp files, but before doing the final move (and + invoking TfAtomicRenameUtil). + + Can also provide a post_launch_callback, which will be executed after the + testAtomicRenameUtil process is launched. This can be used in conjunction with + wait_for_file to do some actions at a point when we know the process is running, + but before it has invoked TfAtomicRenameUtil. + """ + expected_str = "succeed" if expect_success else "fail" + # Print to sys.stderr, since that's what testAtomicRenameUtil will print to if it fails + print("#" * 80, file=sys.stderr, flush=True) + print(f"Running {testAtomicRenameUtil_exe_path} - expected to {expected_str}", file=sys.stderr, flush=True) + proc = launch_testAtomicRenameUtil( + do_retry, + self.retry_wait_seconds, + self.testingdir, + wait_for_file=wait_for_file) + try: + if post_launch_callback is not None: + post_launch_callback(proc) + proc.wait(TIMEOUT) + temp_files = get_temp_files(self.testingdir) + # Clean up any leftover temp files + for temp_file in temp_files: + os.unlink(temp_file) + print(f" ...done running {testAtomicRenameUtil_exe_path}", file=sys.stderr, flush=True) + if expect_success: + self.assertEqual(proc.returncode, 0) + self.assertFalse(temp_files) + self.assert_new_content() + else: + self.assertNotEqual(proc.returncode, 0) + finally: + proc.terminate() + + def assertSuccess_testAtomicRenameUtil( + self, + do_retry: bool, + wait_for_file: str = "", + post_launch_callback: Optional[Callable[[subprocess.Popen], None]] = None, + ): + self._assert_testAtomicRenameUtil( + do_retry, True, wait_for_file=wait_for_file, post_launch_callback=post_launch_callback + ) + + def assertFailure_testAtomicRenameUtil( + self, + do_retry: bool, + wait_for_file: str = "", + post_launch_callback: Optional[Callable[[subprocess.Popen], None]] = None, + ): + self._assert_testAtomicRenameUtil( + do_retry, False, wait_for_file=wait_for_file, post_launch_callback=post_launch_callback + ) + + def wait_for_temp_files(self, proc: subprocess.Popen): + start = time.perf_counter() + while not get_temp_files(self.testingdir): + time.sleep(RETRY_WAIT_SEC) + elapsed = time.perf_counter() - start + if elapsed >= TIMEOUT: + proc.terminate() + self.fail("Timed out waiting for temp file to be created") + + def setUp(self): + self.assertTrue(os.path.isfile(testAtomicRenameUtil_exe_path)) + self.tempdir = tempfile.mkdtemp(dir=os.getcwd()) + self.testingdir = self.tempdir + + def tearDown(self): + if self.tempdir: + shutil.rmtree(self.tempdir) + + def test_fileLockedRetry(self): + """ + Test retrying if another process has a lock on the file we wish to write over + """ + # first, test that if no handles are open to test_file_final, it works, with or without retries + self.assertSuccess_testAtomicRenameUtil(do_retry=False) + self.assertSuccess_testAtomicRenameUtil(do_retry=True) + + sentry_file = os.path.join(self.testingdir, SENTRY_FILENAME) + final_file = os.path.join(self.testingdir, TEST_FILE_FINAL) + with open(final_file, "w", encoding="utf8") as writer: + # Grab a handle, and write old content to file + writer.write(OLD_CONTENT) + + # Now try running testAtomicRenameUtil without retries - it should fail + self.assertFailure_testAtomicRenameUtil(do_retry=False) + + self.assertFalse(get_temp_files(self.testingdir)) + + # Now we get our "real" test that our rety-on-lock works. + # Rather than just trying to grab a lock a bunch of times, at + # the same time we try to write a bunch of times, and hope that + # there's some collisions, we try to ENSURE that there is a + # collision - but this takes some coordination between this + # process (the TEST PROCESS, which is monitoring and creating + # locks), and the WRITING PROCESS, which is actually running + # TfSafeOutputFile::Replace / TfAtomicRenameUtil. + + # The intended flow is: + # - in this TEST PROCESS: + # - launch the WRITING PROCESS + # - in the WRITING PROCESS: + # - start TfSafeOutputFile::Replace, which will + # create the temporary files (but not commit / do final move) + # - then pause, until a sentry file is created + # - in this TEST PROCESS: + # - we wait until the temp file is created + # - then we grab a lock on the final file + # - then we create the sentry file + # - then we sleep / hold onto the lock for shortish time + # (ie, an amount of time that should be < the total amount + # of retry time in the WRITING PROCESS) + # - in the WRITING PROCESS: + # - we see that the sentry file has been created + # - we try to commit / do the final move + # - this should initially FAIL, because the TEST PROCESS + # has a lock on the final file + # - we enter our retry loop + # - in this TEST PROCESS: + # - we finish our short sleep, and release the lock on the + # final file + # - in the WRITING PROCESS: + # - we should finally succeed, because the lock has been released + + def wait_for_temp_then_create_then_release(proc: subprocess.Popen): + # wait until a temp file shows up, so we know that the proc we launched is running + self.wait_for_temp_files(proc) + # create the wait_for_file + with open(sentry_file, "w", encoding="utf8") as _writer: + pass + # Do one more sleep, just to ensure we hang onto the lock for a bit + time.sleep(RETRY_WAIT_SEC) + # then release the lock + writer.close() + + # Now try launching testAtomicRenameUtil with retries, and pass a callback which waits for + # temp files, then creates the wait_for_file, then releases the lock + self.assertSuccess_testAtomicRenameUtil( + do_retry=True, + wait_for_file=sentry_file, + post_launch_callback=wait_for_temp_then_create_then_release + ) + + def test_noFilePermsNoRetry(self): + """Test that if we don't have permissions to move, we fail immediately without retry/waiting""" + # set a long retry wait time, so we can be sure whether or not it retried + self.retry_wait_seconds = 1.0 + + # check that we fail quickly, both if retries are on or off + for do_retries in (False, True): + sentry_path = os.path.join(self.tempdir, f"sentry_file_do_retry_{do_retries}") + + # We need to make a directory that tests will be run in, + # that we can lock; we need to leave the permissions + # on tempdir writable, as that is where we will be writing + # our sentry file, which needs to happen AFTER we lock the + # directory + self.testingdir = os.path.join(self.tempdir, f"do_retries_{do_retries}") + os.mkdir(self.testingdir) + + # we want to test that what happens if there is a permissions error when we try to "commit" the + # TfSafeOutputFile + # we do this by locking down the directory perms on tempdir + + # However, if we do that before even launching testAtomicRenameUtil, then it won't even be able to + # create the temp files, and will never even get to the point where it tries to commit + + # so we need to make a callback that locks down the directory perms AFTER the proc is launched, and we + # can confirm temp files were made + + def wait_for_temp_then_lock_then_create(proc: subprocess.Popen): + # wait until a temp file shows up, so we know that the proc we launched is running + self.wait_for_temp_files(proc) + + # now lock the directory + folder_perms_deny(self.testingdir) + + # then create the wait_for_file + with open(sentry_path, "w", encoding="utf8") as _writer: + pass + # then start our timer + self.timer_start = time.perf_counter() + + try: + self.assertFailure_testAtomicRenameUtil( + do_retries, + wait_for_file=sentry_path, + post_launch_callback=wait_for_temp_then_lock_then_create, + ) + timer_end = time.perf_counter() + # Check that it didn't retry, and failed quickly + self.assertLess(timer_end - self.timer_start, self.retry_wait_seconds) + finally: + folder_perms_reset(self.testingdir) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("testAtomicRenameUtil_exe_path") + known_args, unittest_args = parser.parse_known_args() + + testAtomicRenameUtil_exe_path = known_args.testAtomicRenameUtil_exe_path + + unittest_args.insert(0, sys.argv[0]) + unittest.main(argv=unittest_args)