Skip to content

Add replaceFile #200

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open

Add replaceFile #200

wants to merge 5 commits into from

Conversation

zlonast
Copy link

@zlonast zlonast commented May 17, 2025

@zlonast zlonast changed the title Add replaceFile [Draft] Add replaceFile May 17, 2025
@zlonast zlonast force-pushed the master branch 4 times, most recently from d74c63b to 9c9dc0e Compare May 17, 2025 21:04
@@ -709,6 +710,30 @@ renamePath opath npath =
(`ioeAddLocation` "renamePath") `modifyIOError` do
renamePathInternal opath npath

-- | 'replaceFile' replaces one file with another file. The replacement file
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to elaborate explicitly how replaceFile is different from renameFile.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added more description

Copy link

@noughtmare noughtmare May 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the documentation would help me much more if you add something like: "In constrast to renameFile, replaceFile ... (is atomic?) You should use this when ... For example, consider this program ..."

In other words, the current explanation raises these questions with me:

  • What do renamePath and ReplaceFileW do? (Perhaps this is just meant for people who are familiar with those functions and I should just ignore that sentence.)
  • What can go wrong if I use renameFile? Why is it important that it is atomic?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Windows now renameFile is MoveFileEx and replaceFile is ReplaceFileW

Atomic Operation in ReplaceFile:

  1. Creates a temporary backup of the original file (backup)
  2. Moves the new file to the original file's location
  3. Deletes the backup

On failure at step 2:

  • Original file remains in backup
  • New file stays in its original location

System preserves both versions!

For Comparison: MoveFileEx Behavior:

  1. Deletes the target file
  2. Moves the new file to target location

On failure at step 2:

  • Target file is already deleted
  • New file remains in its original location

Data is lost!

@@ -52,6 +52,7 @@ module System.Directory.OsPath
, copyFile
, copyFileWithMetadata
, getFileSize
, replaceFile
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the convention is to provide the same set of functions both from System.Directory and from System.Directory.OsPath.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@zlonast zlonast force-pushed the master branch 2 times, most recently from 22cf56c to cb53340 Compare May 18, 2025 15:22
@zlonast zlonast marked this pull request as ready for review May 18, 2025 15:26
@zlonast zlonast changed the title [Draft] Add replaceFile Add replaceFile May 18, 2025
@Rufflewind
Copy link
Member

@Rufflewind Rufflewind added the blocked: upstream Progress cannot be made until an upstream issue is resolved. label May 21, 2025
@noughtmare
Copy link

As an outsider, it seems that replaceFile should always be preferred over renameFile, because it is guaranteed to be atomic on all platforms. Would it make sense to eventually replace the implementation of renameFile with this new replaceFile function? Would that be a backwards compatible change?

@Bodigrim
Copy link
Contributor

Bodigrim commented Aug 5, 2025

* We'll have to wait until the upstream change is released to Hackage: [Add ReplaceFileW win32#240](https://github.com/haskell/win32/pull/240)

I think it's merged and released now, in Win32-2.14.2.0.

@zlonast
Copy link
Author

zlonast commented Aug 6, 2025

@Rufflewind
I'm not really sure if it's worth adding this to the tests, but I'll describe the behavior that will be used in other places

Blocking the target name:

HANDLE hLock = CreateFile(
    L"C:\\target.exe", 
    GENERIC_READ,
    FILE_SHARE_READ, // We prohibit exclusive access
    NULL,
    CREATE_ALWAYS,
    FILE_ATTRIBUTE_NORMAL,
    NULL
);

ReplaceFile(
    L"C:\\target.exe", 
    L"C:\\new_version.exe",
    NULL, 
    0, 
    NULL, 
    NULL
); // Return ERROR_UNABLE_TO_MOVE_REPLACEMENT

CloseHandle(hLock);

Access rights violation:

// Set read-only permissions for the target directory:
icacls C:\ /deny Everyone:(WD)

ReplaceFile(L"C:\\app.exe", L"C:\\update.tmp", NULL, 0, NULL, NULL);
// Return ERROR_UNABLE_TO_MOVE_REPLACEMENT

Recovery in case of failure

if (!ReplaceFile(L"app.exe", L"update.tmp", NULL, 0, NULL, NULL)) {
    DWORD err = GetLastError();
    
    if (err == ERROR_UNABLE_TO_MOVE_REPLACEMENT) {
        // 1. Find .RXXXX file
        // 2. Try to restore manually:
        MoveFile(L"app.exe.R4987", L"app.exe");
        
        // 3. Notify user
        MessageBox(NULL, L"Update error. Previous version restored.", L"Error", MB_OK);
    }
}

@zlonast zlonast requested review from Bodigrim and noughtmare August 6, 2025 13:05
@noughtmare
Copy link

noughtmare commented Aug 6, 2025

Is there any reason to ever use renameFile over replaceFile? Can't we just fix the windows implementation of renameFile instead of introducing a whole new function?

If we are going to have two functions it should be very clear from the documentation what the differences are and when you should use one over the other. You described (part of?) it in this comment, but that is not really reflected in the documentation.

@Bodigrim
Copy link
Contributor

Bodigrim commented Aug 6, 2025

AFAIU from reading StackOverflow on Windows:

  • renameFile old new (aka MoveFileEx) retains the identity of old file such as attributes, ownership, etc. and only replaces its name with new,
  • replaceFile old new is vice versa: it retains the identity of new file, but replaces its contents with the contents of old.

@zlonast could you confirm that my understanding is correct?

What I don't know is how to achieve the semantics of Windows replaceFile on Unix. Is there a call to preserve the identity of the old file and replace contents only?

@zlonast
Copy link
Author

zlonast commented Aug 7, 2025

@noughtmare @Bodigrim

Is there any reason to ever use renameFile over replaceFile? Can't we just fix the windows implementation of renameFile instead of introducing a whole new function?

I think these are different functions, as a result they produce different attributes.

Do you think it's normal for a general package to have some useless features for a particular platform? If you think it's not, let me know.

If we are going to have two functions it should be very clear from the documentation what the differences are and when you should use one over the other. You described (part of?) it in #200 (comment), but that is not really reflected in the documentation.

Yes, I think you're right, I'll fix it.

@zlonast could you confirm that my understanding is correct?

Yes

What I don't know is how to achieve the semantics of Windows replaceFile on Unix. Is there a call to preserve the identity of the old file and replace contents only?

As I understand it, it doesn't make sense.

From what I understand, I need rename on unix and replaceFile on windows to implement atomicReplaceFile and atomicWriteFile. (I think it's worth continuing in file-io)

@Bodigrim
Copy link
Contributor

Bodigrim commented Aug 7, 2025

@zlonast I don't quite follow all the details, but if the goal is to implement something in file-io then contributing it into directory will be of little help: somewhat awkwardly it's directory who depends on file-io, not vice versa.

@Rufflewind Rufflewind removed the blocked: upstream Progress cannot be made until an upstream issue is resolved. label Aug 8, 2025
@Rufflewind Rufflewind added the type: a-feature-request This is a request for a new feature. label Aug 8, 2025
@Rufflewind
Copy link
Member

To make the tests pass, try updating the Win32 version number in .github/workflows/build.yml:

- { os: windows-latest, stack: lts-15.3, stack-extra-deps: "bytestring-0.11.3.0, file-io-0.1.4, filepath-1.4.100.0, time-1.9.3, Win32-2.14.1.0", overrides: "before_prepare() { sed -i.bak -e /CreateSymbolicLinkW/d -e /GetFinalPathNameByHandleW/d configure.ac; }" }
- { os: windows-latest, stack: lts-17.5, stack-extra-deps: "bytestring-0.11.3.0, file-io-0.1.4, filepath-1.4.100.0, time-1.9.3, Win32-2.14.1.0" }
- { os: windows-latest, stack: lts-22.7, stack-extra-deps: "bytestring-0.11.5.3, file-io-0.1.4, filepath-1.5.2.0, os-string-2.0.2, time-1.14, Win32-2.14.1.0", stack-package-flags: "{directory: {os-string: true}, file-io: {os-string: true}, Win32: {os-string: true}}", ghc-flags: -Werror=deprecations }

--
-- The operation on Windows may fail with:
--
-- ERROR_FILE_NOT_FOUND 2 (0x2)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To my understanding, the Haskell Win32 library does not expose the underlying Win32 API error code in the IOException object, so the information here isn't really usable to users. It should really be rephrased as IOErrorTypes instead, and preferably merged with the Unix section if possible to reduce the platform-specific content for users. It's probably not worth documenting every single error case either, just the most commonly encountered ones.

@@ -85,6 +85,9 @@ removePathInternal False = Posix.removeLink . getOsString
renamePathInternal :: OsPath -> OsPath -> IO ()
renamePathInternal (OsString p1) (OsString p2) = Posix.rename p1 p2

replaceFileInternal :: OsPath -> OsPath -> Maybe OsPath -> IO ()
replaceFileInternal (OsString p1) (OsString p2) _ = Posix.rename p1 p2
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replaceFileInternal = renamePathInternal

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: a-feature-request This is a request for a new feature.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants