Skip to content

Modernize to true value semantics (retire the class-in-struct + Mutex<String>) #7

Description

@leogdion

Copied from brightdigit/brightdigit.com#162, which remains open there as the consumer-side tracking issue. All file paths below are relative to this repository's root.

Problem

File and Folder are structs, but they don't behave like values. Both wrap a reference:

// Sources/File.swift:12-21, Sources/Folder.swift:12-21
public struct File: Location {
  public let storage: Storage<File>
}

// Sources/Storage.swift:13-21
public final class Storage<LocationType: Location>: Sendable {
  private let pathStorage: Mutex<String>
  internal var path: String { pathStorage.withLock { $0 } }
}

The only reason Storage is a class is so that move/rename can mutate path through a let, letting every copy of a File "follow" the item on disk. That's a 2019-era ergonomic trick, and it is not a file-descriptor handle: Storage holds a String and nothing else. Nothing is open, there's no lifecycle, no file position, no resource to release.

Storage.move (Sources/Storage.swift:117-135) is the only mutation of path in the entire library:

pathStorage.withLock { $0 = canonical }                               // Storage.swift:128
pathStorage.withLock { $0 = canonical.appendingSuffixIfNeeded("/") }  // Storage.swift:130

The Mutex is a symptom, not a fix

import Synchronization and Mutex<String> exist solely to make that shared mutable state Sendable under Swift 6 without an @unchecked conformance (see the comment at Storage.swift:14-18). It's the only concurrency primitive in the library, and it also drags the platform floor to macOS 15 / iOS 18 / tvOS 18 / watchOS 11 (Package.swift:13-19). If the path were immutable, Storage would be Sendable for free — and so would File/Folder.

The aliasing surprise

Two File values that compare equal (== compares paths, Location.swift:102-104) may share one Storage. A rename through one silently rewrites the other:

let a = try folder.createFile(named: "file.json")
let b = a                     // struct copy — looks like a value
try a.rename(to: "renamed")   // mutating through a `let`, no `mutating` keyword
b.name                        // "renamed.json"  ← b changed too
b == a                        // true

There is no mutating marker and no var anywhere to warn the reader. This also means File/Folder are values you cannot reason about locally: holding one is holding a reference to something another thread may rewrite between two accessor calls (path and name can disagree across a single expression).

The existing test suite actively depends on this, which is the clearest evidence it is load-bearing behavior rather than an accident — Tests/FilesTests/FilesTests+Enumeration.swift:124-126 renames items while iterating and then re-reads names from the same sequence:

for folder in sequence { try folder.rename(to: "Folder " + folder.name) }
XCTAssertEqual(sequence.names().sorted(), expectedNames)

Ecosystem comparison

Precedent is uniformly the opposite of what we do. Verified against local checkouts:

Library Path type Move API Enumeration
swift-system (Apple, first-party) public struct FilePath: Sendable — immutable value (Sources/System/FilePath/FilePath.swift:41) none none
NIOFileSystem (apple/swift-nio) FilePath (reuses swift-system) func moveItem(at:to:) async throwsVoid (Sources/_NIOFileSystem/FileSystem.swift:535) DirectoryEntries: AsyncSequence, Sendable (DirectoryEntries.swift:24)
Foundation URL — never rewritten in place FileManager.moveItem(at:to:)Void contentsOfDirectory → array

Two details worth internalizing:

  • swift-system's appending is non-mutating and consuming: public __consuming func appending(_ other: __owned String) -> FilePath (FilePathSyntax.swift:532). Deriving a new path returns a new value. There is no path type in the ecosystem that rewrites itself when the file moves.
  • swift-system deliberately refuses Sendable on the thing that actually is a handle. FileDescriptor's conformance is commented out (Sources/System/FileDescriptor.swift:641-643) pending apple/swift-system#112. So Apple's own line is: the path is a Sendable value; the open descriptor is not. We currently have it backwards — our path type is a Sendable reference.

What adopting FilePath would and would not buy

swift-system 1.7.5 is already in our dependency graph transitively (Package.resolved), pulled in by swift-configuration and swift-subprocess. So adding it as a direct dependency of Files costs no new resolution.

  • Would buy: a real path type replacing String, with correct component parsing, Windows drive-prefix handling, and normalization. That would let us delete much of Path.swift, String+Files.swift, and the branchy validatedPath/makeParentPath logic (Storage.swift:30-110) — which we currently maintain by hand, including our own canonical-vs-native separator dance.
  • Would NOT buy: anything about this issue's actual problem. FilePath does not solve mutation or Sendable — those are consequences of our class, not of String. And there is nothing to adopt for the operations we care about: Sources/System/FileSystem/ contains only FileFlags, FileMode, FileType, Identifiers, Stat. No move, no rename, no directory enumeration. We would keep calling FileManager for all of it.

Recommendation: treat FilePath adoption as a separate, optional follow-up. It is orthogonal to value semantics and should not be bundled into this change.

Proposed design

Make the path immutable and have mutating operations return new values — exactly as copy(to:) already does today (Location.swift:161-166).

Step 1 — immutable storage. Replace Mutex<String> with let path: String. Storage can then be a struct, or be deleted entirely with the path stored directly in File/Folder. I lean toward deleting it: it exists only to be a reference, and Location's storage requirement (Location.swift:15-19) is already documented as not-public-API. Removing it also removes import Synchronization, which may let us lower the platform floor (see open questions).

Step 2 — move/rename return new values.

// before
public func rename(to newName: String, keepExtension: Bool = true) throws(LocationError)
public func move(to newParent: Folder) throws(LocationError)

// after
@discardableResult public func rename(to newName: String, keepExtension: Bool = true) throws(LocationError) -> Self
@discardableResult public func move(to newParent: Folder) throws(LocationError) -> Self

This makes the three operations symmetric — move, copy, and rename all return the new location.

What breaks

In-place mutation becomes rebinding:

try file.move(to: folder)              // before: file now points at the new path
let moved = try file.move(to: folder)  // after: file still points at the old path

@discardableResult keeps existing call sites compiling, which is a double-edged sword: code that relied on the old value updating will silently change behavior rather than erroring. Recommend shipping without @discardableResult on the first pass so every affected site is a compile error, then adding it back if the ergonomics annoy. This is the main risk in the whole proposal and deserves a deliberate decision.

What survives (the key advantage)

  • Sequence conformance is untouched. ChildSequence/ChildIterator need no changes at all. loadItemNames() (Folder+Children.swift:114-118) reads folder.path synchronously during iteration; with an immutable path that read is simply now guaranteed stable instead of racy.
  • Equatable, CustomStringConvertible, Sendable all survive, and Sendable gets easier — no Mutex, no @unchecked.
  • Free reduce/map/lazy from Sequence keep working, so count(), names(), first, last(), recursive, includingHidden (Folder.ChildSequence.swift) are unaffected.

One correction to the framing in the original brief: ChildSequence is not Sendable today and this change does not make it so — it stores a FileManager (Folder+Children.swift:15), which isn't Sendable. That's a pre-existing, separate matter.

ChildSequence.move(to:) (Folder.ChildSequence.swift:73-75) is the one internal site needing real thought: it currently mutates each child in place. It would become -> [Child] or stay Void and simply discard the new values.

~Copyable and ownership: recommendation is no

Swift 6.4 supports the full noncopyable feature set — verified that ~Copyable structs, consuming/borrowing parameters, and ~Copyable generics all typecheck under -swift-version 6 on the pinned 6.4.x-snapshot toolchain.

Relevant proposals: SE-0390 (noncopyable structs/enums), SE-0426 (BitwiseCopyable), SE-0427 (noncopyable generics), SE-0377 (consuming/borrowing modifiers).

~Copyable is the wrong tool here, and adopting it would be a mistake. Noncopyable types model unique ownership of a resource — something with a lifecycle that must not be duplicated or must be explicitly consumed (an open file descriptor, a lock token, a buffer). A path is the opposite: it is a plain description of a location, freely and cheaply copyable by nature, and copying it is never an error. Making File noncopyable would:

  • break Equatable comparison ergonomics and every Sequence-based API (Sequence.Element is copyable; ChildSequence would need a full redesign),
  • force consuming/borrowing annotations across the whole public surface,
  • deliver no safety benefit, because there is no resource to leak or double-free.

Note the ecosystem agrees: swift-system's FilePath is an ordinary copyable struct; FileDescriptor — the actual handle — is the type with the ownership concerns. If we ever add a real open-handle type to Files, that is the ~Copyable candidate. Not this.

consuming/borrowing on move/copy: also no, for now. These are performance annotations, and File after this change is a struct wrapping a single String — one retain/release at worst. There is no measured hot path here (the enumeration cost is dominated by FileManager.contentsOfDirectory, Folder+Children.swift:115). Adopting them would add API-surface complexity and ownership-diagnostic noise for no measured win. Premature. Revisit only with a benchmark that shows it matters.

Migration path

Real grep counts, not estimates:

Repo move/rename call sites Notes
Publish (brightdigit-com-260406) 0 git grep '\.move(|\.rename(' over the whole repo returns nothing
Root (tidy-summit/Sources) 0 no import Files anywhere in Sources/ or Tests/ — confirmed, uses Files only through Publish
Files' own tests 7 FilesTests+Operations.swift:89,95,105,115; FilesTests+MovingCopying.swift:38,66; FilesTests+Enumeration.swift:125

The consumer blast radius is effectively zero. Publish touches Files only through non-mutating APIs — enumeration (6 for … in folder.files/subfolders sites in PublishingStep+Files.swift:74,81, MarkdownFileHandler+Loading.swift:47,134,147, MarkdownFileHandler.swift:19), plus write/read/createFile/createSubfolder/file(at:)/copy(to:)none of which change under this proposal. The 25 files in Publish that import Files are unaffected.

So the churn is confined to Files' own test suite: 7 call sites, of which FilesTests+Enumeration.swift:125 needs a genuine rewrite (it renames during iteration and asserts on the mutated sequence).

Staging:

  1. Make Storage.path a let; drop Mutex and import Synchronization. Change move/rename to return Self. Update the 7 tests.
  2. Collapse Storage into File/Folder and remove the storage requirement from Location (Location.swift:15-19). Source-breaking only for anyone who touched storage — nobody does outside the library (21 internal storage. references).
  3. (Optional, separate issue) Evaluate FilePath to replace String paths and delete our hand-rolled path normalization.
  4. (Optional) Reconsider the platform floor once Synchronization is gone.

Non-goals

  • Not adopting the actor / AsyncSequence route. Already evaluated and rejected: it forces async on every accessor (path, name, extension, parent, creationDate, …) and loses Sequence's free reduce/map/lazy, forcing a hand-written replacement for count(), names(), first, last(). Value semantics gets us Sendable correctness without going async — that's the whole point.
  • Not adopting ~Copyable — see reasoning above.
  • Not adopting FilePath as part of this change (orthogonal; separate issue).
  • Not lowering platform floors in this change, even though removing Mutex makes it possible.
  • Not doing this before the release checkpoint completes. Files is mid-branch-pin; this lands after the dependency release checkpoint and tagging finish.

Open questions

  1. @discardableResult on move/rename? Omitting it turns every affected site into a compile error (safer migration); including it preserves source compatibility but risks silent behavior changes. I lean omit.
  2. Delete Storage entirely, or keep it as an immutable struct? Deleting is cleaner but removes Location.storage and init(storage:) from the public protocol. Keeping it is a smaller diff and preserves the protocol shape.
  3. What should ChildSequence.move(to:) return[Child], or stay Void?
  4. Is anyone outside these two repos consuming this fork? If the fork is only ever used via Publish, the source-break budget is essentially unlimited and we can do steps 1+2 in one pass.
  5. Lower the platform floor once Synchronization is gone? macOS 15 / iOS 18 exist only for Mutex; the rest of the library is plain Foundation.
  6. Rewrite of FilesTests+Enumeration.swift:125 — should renaming-during-iteration remain a supported pattern at all? Under value semantics it becomes "collect, then rename", which is arguably the more honest API.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions