Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,21 @@ pending until the returned function is called, so a test can model a native
that is slow to answer. Later enqueues wait behind it, as they do on a
device.

iOS now keeps a new request through a power loss right after `mutate()`
resolves. Before, no rename into place was flushed to disk, so the request
directory could come back holding only `entry.json.tmp`, which nothing read:
the request was silently lost. Every atomic write on iOS (entries, data
bodies, outcome journal, task map, settings) now syncs its directory after
the rename, as Android does, and an entry's first save also syncs the queue
root. Form, file and chunked bodies are placed by a rename or move into the
request directory, and the entry save that follows syncs that directory
before `mutate()` resolves. A failed directory sync is logged and does not
fail the write. At
startup, iOS turns a lone `entry.json.tmp` that reads as a valid entry into
the entry, logs it, and runs the request as usual. A missing body then
settles `error` with `errorKind: 'file'`. A tmp that does not read is left
on disk. A tmp next to a readable `entry.json` is deleted.

## 11.1.0

Added `takeDiagnostics()`. On Android it returns the module's recent
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,11 @@ while the app was dead from being sent twice.

1. **Write-ahead.** Entry, descriptor, and staged body persist before any
attempt. `mutate()` resolves after the row and every staged body copy are
durably on disk (temp file plus rename), so the caller may delete its
source file then. A native failure rejects with a code: `E_INVALID`,
durably on disk (temp file, rename, then a sync of the directory), so the
caller may delete its source file then. A failed directory sync is logged
and does not fail the call. The library uses `fsync`, which on iOS does not
force the drive to empty its cache. A native failure rejects with a code:
`E_INVALID`,
`E_RUNNING`, `E_FILE_MISSING`, or `E_STORAGE`. `E_INVALID` is malformed
input native cannot send: a non-http(s) URL, header names or values the
platform HTTP client rejects, a GET with a body, or parts that do not
Expand Down
45 changes: 42 additions & 3 deletions ios/FileIO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import Foundation
// Small file helpers shared by the store, the journal, the task map and body
// staging. Every durable write is tmp + fsync + rename, so a reader never sees
// a half-written file: after a crash there is either the old file or the new
// one, plus at most a stray `.tmp` that the next write replaces.
// one, plus at most a stray `.tmp` that the next write replaces. The rename
// itself survives a power loss only after its directory is synced, so
// `writeAtomically` syncs the directory after every rename, as Android does.
enum FileIO {
struct IOError: LocalizedError {
let message: String
Expand All @@ -15,10 +17,20 @@ enum FileIO {
}

/// Writes `data` to `url.tmp`, flushes it to disk, then renames it onto `url`.
/// On a failure the tmp file is deleted, so a partly written file does not
/// hold space that a retry needs.
static func writeAtomically(_ data: Data, to url: URL) throws {
// Runs last: after the rename, so the new name is what the sync makes
// durable. After a failure, it makes the tmp delete durable.
defer { syncDirectoryOrLog(url.deletingLastPathComponent()) }
let tmp = tmpURL(for: url)
try writeSynced(data, to: tmp)
try rename(tmp, onto: url)
do {
try writeSynced(data, to: tmp)
try rename(tmp, onto: url)
} catch {
try? FileManager.default.removeItem(at: tmp)
throw error
}
}

/// Writes and fsyncs a file in place. Callers rename it afterwards.
Expand Down Expand Up @@ -49,6 +61,33 @@ enum FileIO {
FileManager.default.fileExists(atPath: url.path)
}

/// Flushes a directory. Tests replace it to watch the syncs or fail them.
static var syncDirectory: (URL) throws -> Void = fsyncDirectory

/// Flushes a directory, so the names that renames and new files put in it
/// survive a power loss. Plain fsync, as Android does. On Apple platforms
/// fsync does not make the drive empty its cache; F_FULLFSYNC would.
static func fsyncDirectory(_ url: URL) throws {
let fd = open(url.path, O_RDONLY)
guard fd >= 0 else {
throw IOError(message: "cannot open \(url.lastPathComponent) to sync it: errno \(errno)")
}
defer { close(fd) }
guard fsync(fd) == 0 else {
throw IOError(message: "fsync \(url.lastPathComponent) failed: errno \(errno)")
}
}

/// A failed sync is logged, never thrown: the rename it follows already
/// happened, so the write still counts.
static func syncDirectoryOrLog(_ url: URL) {
do {
try syncDirectory(url)
} catch {
NSLog("[RNFileUploader] cannot sync \(url.lastPathComponent): \(error.localizedDescription)")
}
}

/// Creates a directory that holds device-local upload state and keeps it
/// out of iCloud and iTunes backups.
static func makeLocalDirectory(_ url: URL) {
Expand Down
3 changes: 3 additions & 0 deletions ios/QueueCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ final class QueueCoordinator {
queue.asyncAfter(deadline: .now() + .milliseconds(ms), execute: block)
}
settings = store.loadSettings()
// First: a promoted entry must load below, and never count as a
// directory with no entry.
store.promoteInterruptedSaves()
finishSetAsideForgets()
// Synchronous, before any session exists, so getRequests() is warm by
// the time JS can call it, legacy rows included.
Expand Down
84 changes: 77 additions & 7 deletions ios/QueueStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import Foundation
/// Next to the directories: `settings.json` and the `v10-imported` marker.
/// Writes are tmp + fsync + rename. A corrupt or half-written file reads as
/// absent. Synchronous on a private serial queue, like the v9 store.
///
/// `entry.json.tmp` alone in a directory is an entry save that a crash or a
/// power loss cut short. Boot finishes it (`promoteInterruptedSaves`).
final class QueueStore {
static let shared = QueueStore(
root: FileIO.applicationSupport().appendingPathComponent("RNFileUploaderChunked", isDirectory: true))
Expand Down Expand Up @@ -59,10 +62,21 @@ final class QueueStore {

/// Throws on a write failure. An entry that did not persist must fail the
/// enqueue.
///
/// `writeAtomically` syncs the entry directory after the rename, so a
/// power loss cannot undo it: the first save would leave only
/// entry.json.tmp, and a later save would bring back an older entry. The
/// first save also syncs the root, which holds the new directory's name.
/// A failed sync is logged and the save still counts. If a power loss then
/// undoes a first save, boot promotes the lone tmp.
func save(_ entry: QueueEntry) throws {
try queue.sync {
try FileManager.default.createDirectory(at: dir(entry.id), withIntermediateDirectories: true)
try FileIO.writeAtomically(Self.encode(entry), to: fileURL(entry.id, Self.entryName))
let d = dir(entry.id)
let url = d.appendingPathComponent(Self.entryName)
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
let first = !FileIO.exists(url)
try FileIO.writeAtomically(Self.encode(entry), to: url)
if first { FileIO.syncDirectoryOrLog(root) }
}
}

Expand All @@ -78,9 +92,63 @@ final class QueueStore {
}

/// Deletes the id directory: entry, body, blob, part files, and a v9
/// manifest.
/// manifest. A stray entry.json.tmp goes first, and that delete is synced:
/// a removal cut short must never leave the tmp alone, because boot would
/// promote it and bring the request back.
func remove(_ id: String) {
queue.sync { _ = try? FileManager.default.removeItem(at: dir(id)) }
queue.sync {
let d = dir(id)
let tmp = FileIO.tmpURL(for: d.appendingPathComponent(Self.entryName))
if (try? FileManager.default.removeItem(at: tmp)) != nil { FileIO.syncDirectoryOrLog(d) }
_ = try? FileManager.default.removeItem(at: d)
}
}

// MARK: - Boot repair

/// Runs at boot, before anything reads, counts or cleans the entry
/// directories. Finishes entry saves that a crash or a power loss cut
/// short. For each id directory that holds `entry.json.tmp`:
/// - No `entry.json`, and the tmp decodes to the entry of this directory:
/// the tmp is renamed onto `entry.json` and loads like any entry. The
/// app asked for the request, even when the process died before its
/// enqueue resolved.
/// - No `entry.json`, and the tmp does not decode: both stay as they are.
/// - A readable `entry.json`: the tmp is a later save that did not finish.
/// `entry.json` wins and the tmp is deleted.
/// - An unreadable `entry.json`: both stay as they are.
/// It reads nothing but `entry.json.tmp`, so v9 files stay as they are. It
/// skips set-aside directories, which are hidden. Returns the promoted ids.
@discardableResult
func promoteInterruptedSaves() -> [String] {
queue.sync {
var promoted: [String] = []
for d in subdirectories() {
let url = d.appendingPathComponent(Self.entryName)
let tmp = FileIO.tmpURL(for: url)
guard FileIO.exists(tmp) else { continue }
if FileIO.exists(url) {
if Self.read(url) != nil { _ = try? FileManager.default.removeItem(at: tmp) }
continue
}
guard let e = Self.read(tmp), dir(e.id).lastPathComponent == d.lastPathComponent else {
NSLog("[RNFileUploader] \(d.lastPathComponent)/\(tmp.lastPathComponent) is not an entry of this "
+ "directory; left as it is")
continue
}
do {
try FileIO.rename(tmp, onto: url)
} catch {
NSLog("[RNFileUploader] cannot recover entry \(e.id): \(error.localizedDescription)")
continue
}
FileIO.syncDirectoryOrLog(d)
FileIO.syncDirectoryOrLog(root)
promoted.append(e.id)
NSLog("[RNFileUploader] recovered entry \(e.id) from an entry save that did not finish")
}
return promoted
}
}

/// Deletes the id directory when it holds no `entry.json`: v9 files that
Expand Down Expand Up @@ -214,9 +282,11 @@ final class QueueStore {
/// Never a chunked blob (`blob`, `blob-<uuid>`): that file was moved in,
/// so it can be the only copy, and a same-id enqueue adopts it
/// (`adoptableBlob`). Never a part file: its entry is gone, so its use is
/// not known. Never `entry.json.tmp`: `FileIO.writeAtomically` does not
/// sync the directory, so after a crash it can be the only trace of an
/// entry whose enqueue already resolved.
/// not known. Never `entry.json.tmp`: boot promotion
/// (`promoteInterruptedSaves`) runs before this cleanup, so a tmp still
/// here is one it could not use: it does not read as this directory's
/// entry, or its rename failed. It can still be the only trace of a
/// request, so the directory stays.
static func isCopiedBodyFile(_ name: String) -> Bool {
let range = NSRange(name.startIndex..., in: name)
return copiedBody.firstMatch(in: name, range: range) != nil
Expand Down
Loading
Loading