packages feed

nixfmt-0.3.0: main/System/IO/Atomic.hs

{- © 2019 Serokell <hi@serokell.io>
 -
 - SPDX-License-Identifier: MPL-2.0
 -}

-- | Atomic file IO.
--
-- This modules allows one to replace the contents of a file atomically by
-- leveraging the atomicity of the @rename@ operation.
--
-- This module is a crossover of the `atomic-write` and `safeio` libraries
-- with the fs-related behaviour of the first and interface of the second.
module System.IO.Atomic
  ( withOutputFile
  ) where

import Control.Exception.Safe (mask, onException)
import Control.Monad (when)
import System.Directory (doesFileExist, removeFile, renameFile)
import System.FilePath (takeDirectory, takeFileName)
import System.IO (Handle, hClose, openTempFileWithDefaultPermissions)
import System.Posix.Files (fileMode, getFileStatus, setFileMode)


-- | Like @withFile@ but replaces the contents atomically.
--
-- This function allocates a temporary file and provides its handle to the
-- IO action. After the action finishes, it /atomically/ replaces the original
-- target file with the temporary one. If the action fails with an exception,
-- the temporary file is cleaned up.
--
-- Since the procedure involves creation of an entirely new file, preserving
-- the attributes is a challenge. This function tries its best, but currently
-- it is Unix-specific and there is definitely room for improvement even on Unix.
withOutputFile ::
     FilePath  -- ^ Final file path
  -> (Handle -> IO a)  -- ^ IO action that writes to the file
  -> IO a
withOutputFile path act = transact begin commit rollback $ \(tpath, th) -> do
    copyAttributes (tpath, th)
    act th
  where
    tmpDir = takeDirectory path
    tmpTemplate = "." <> takeFileName path <> ".atomic"

    begin :: IO (FilePath, Handle)
    begin = openTempFileWithDefaultPermissions tmpDir tmpTemplate

    -- TODO: Support for non-unix platofrms.
    -- TODO: Preserve ctime?
    -- TODO: Preserve extended attributes (ACLs, ...)?
    copyAttributes :: (FilePath, Handle) -> IO ()
    copyAttributes (tpath, _th) = do
      exists <- doesFileExist path
      when exists $ do
        getFileStatus path >>= setFileMode tpath . fileMode

    commit :: (FilePath, Handle) -> IO ()
    commit (tpath, th) = hClose th *> renameFile tpath path

    rollback :: (FilePath, Handle) -> IO ()
    rollback (tpath, th) = hClose th *> removeFile tpath



---
-- Helpers
--

-- | A variant of @bracket@ with two different finalisation actions.
--
-- Semantically, the following is /almost/ true:
--
-- > bracket acquire release = transact acquire release release
--
-- except that @bracket@ always runs the cleanup action exactly once, while
-- here, if the action completes but commit fails, we will still run rollback,
-- so there exists an execution in which both finalisation actions are run.
transact ::
     IO a         -- ^ computation to run first (\"begin transaction\")
  -> (a -> IO b)  -- ^ computation to run on success (\"commit transaction\")
  -> (a -> IO c)  -- ^ computation to run on failure (\"rollback transaction\")
  -> (a -> IO d)  -- ^ computation to run in-between
  -> IO d         -- returns the value from the in-between computation
transact begin commit rollback act =
  mask $ \restore -> do
    a <- begin
    res <- restore (act a) `onException` rollback a
    _ <- commit a `onException` rollback a
    pure res