[Pitch] A noncopyable Win32.FileHandle: ownership and closing

Hi all,

To continue with the proposals for Windows-native file system APIs, I'd like to pitch a noncopyable wrapper for HANDLE in the Win32 namespace. Later proposals build on this type for handle-based APIs.

The full series of proposals is tracked and available in this draft PR:

Feedback is much appreciated!

A Noncopyable Win32.FileHandle

Revision history

  • v1 Initial version.

Introduction

This proposal adds Win32.FileHandle, a noncopyable wrapper that owns a synchronous (non-overlapped) Windows HANDLE. It builds on the Win32 namespace and Win32Error introduced in SYS-0010. Opening a handle is proposed in SYS-0012, and bridging with FileDescriptor in SYS-0013.

Motivation

A HANDLE is Windows' canonical representation of an open file, and the Win32 APIs in this series need a type for it. FileDescriptor is a POSIX abstraction, and on Windows it's a C runtime file descriptor layered over the handle. That works for a POSIX shim, but it makes a poor Windows type in two ways.

The descriptor is not the object

A C runtime descriptor is a second index over the handle and carries state of its own, including a translation mode. In text mode, the runtime rewrites bytes in transit, expanding \n to \r\n on write and collapsing it on read. Anything built on a descriptor inherits a translation setting that is invisible in System's APIs, and Win32 calls on the underlying handle bypass it.

The descriptor also leaks Windows semantics into portable-looking APIs: FileDescriptor.read(fromAbsoluteOffset:into:) moves the file pointer on Windows, unlike POSIX pread (see SYS-0014).

A copied handle is a use-after-close hazard

FileDescriptor is a copyable value with a non-consuming FileDescriptor.close(). Copy it, close one copy, and the other still compiles and names an index. Windows also recycles handle values, so a stale copy may name a different kernel object and lead to I/O on the wrong file.

FileDescriptor keeps the copyable model because it's shipped ABI and predates ~Copyable. Neither constraint applies to new Windows APIs, so we should pursue an owning, noncopyable type instead. A double close or use after close then becomes a compile error, a dropped handle is closed rather than leaked, and every signature says clearly whether it borrows or consumes. System already ships a precedent: Mach.Port is a noncopyable owner that deallocates its port right in deinit and gives up ownership with consuming func relinquish().

Proposed solution

Add Win32.FileHandle, a noncopyable type that owns its handle and closes it with a consuming func close().

#if os(Windows)
// `open` is proposed separately, in SYS-0012.
let file = try Win32.FileHandle.open(path, access: .genericRead)
try parse(file)   // Borrows `file`. If this throws, `deinit` closes it.
try file.close()  // Consumes `file` and reports any error.
try file.close()  // error: 'file' consumed more than once
#endif

Detailed design

Wrapper constants and functions introduced here are @_alwaysEmitIntoClient. All APIs carry the availability of the System release that introduces them. A Win32.FileHandle is always synchronous; see Synchronous handles only.

Win32.FileHandle

extension Win32 {
  /// An open file, device, or pipe.
  ///
  /// This type owns its handle. Call ``close()`` to close it and report any
  /// error. Otherwise, `deinit` closes the handle at the end of its scope and
  /// discards the error. In debug builds, both trap if unsafe code closed the
  /// handle or marked it `HANDLE_FLAG_PROTECT_FROM_CLOSE`.
  ///
  /// The corresponding C type is `HANDLE`.
  @frozen @safe
  public struct FileHandle: ~Copyable, Sendable {
    /// Adopts a raw handle, taking ownership of it.
    ///
    /// Returns `nil` if `raw` is `NULL` or `INVALID_HANDLE_VALUE`.
    ///
    /// - Warning: `raw` must be a synchronous handle that's closed by
    ///   `CloseHandle`. Do not pass:
    ///   - A handle opened with `FILE_FLAG_OVERLAPPED`.
    ///   - A socket, which is overlapped by default and must be closed with
    ///     `closesocket`.
    ///   - A handle you don't own, such as one from `GetStdHandle`, since
    ///     `deinit` closes it.
    ///   - A handle with `HANDLE_FLAG_PROTECT_FROM_CLOSE` set, since closing
    ///     it fails.
    @unsafe
    public init?(unsafelyAdopting raw: HANDLE?)

    /// The raw C handle. Valid only while this value is alive.
    @unsafe
    public var unsafeRawHandle: HANDLE { get }

    /// Gives up ownership and returns the raw handle.
    ///
    /// The caller becomes responsible for closing it.
    @unsafe
    public consuming func relinquish() -> HANDLE

    /// Closes this handle.
    ///
    /// The corresponding C function is `CloseHandle`.
    public consuming func close() throws(Win32Error)
  }
}

Lifetime and error handling

Every handle gets one CloseHandle call, from close() at a point the caller chooses or from deinit at the end of the handle's scope. Closing in deinit prevents a throw between an open and close from leaking the handle. A Win32.FileHandle must never hold a handle marked HANDLE_FLAG_PROTECT_FROM_CLOSE, which CloseHandle leaves open.

CloseHandle's documentation doesn't list failure conditions, but NtClose documents error statuses for 1) an invalid handle and 2) a handle that's protected from closing. Testing shows that CloseHandle reports .invalidHandle for both cases. Win32.FileHandle's ownership prevents this failure unless unsafe code closed the handle or set its protect-from-close flag. Therefore, close() and deinit trap in debug builds if CloseHandle fails with .invalidHandle. Note this can't catch a value that Windows has already reused, so it's only a probabilistic debugging aid.

CloseHandle doesn't wait for the cache manager to write cached data to the device, so close() can't report such a failure. A client must call flush() (SYS-0014) before closing if a write-back failure needs to be surfaced.

unsafeRawHandle and relinquish() are @unsafe escape hatches from the ownership model. unsafeRawHandle may be used with Win32 functions that borrow a handle and have no System wrapper yet, and relinquish() hands ownership to code that will close it.

Two invalid sentinels

CreateFileW reports failure as INVALID_HANDLE_VALUE ((HANDLE)-1), while most other handle-producing functions report failure as NULL. init?(unsafelyAdopting:) is failable so that both are rejected in one place. There is no .invalid member, since both sentinels report only that the call failed, and .invalid would name a handle on which every operation fails. The parameter is HANDLE? to support Win32 functions that return NULL but import as returning HANDLE!.

Note: The C runtime has a third sentinel, _NO_CONSOLE_FILENO ((intptr_t)-2). This is a CRT convention rather than a Win32 one, so it's filtered by FileDescriptor.withWin32HandleIfAvailable(_:) in SYS-0013 instead.

Synchronous handles only

None of the synchronous I/O in SYS-0014 is memory-safe on an overlapped handle: a transfer can report itself complete while the kernel is still writing into the span's memory, or a call can return ERROR_IO_PENDING immediately while the work completes later. Therefore, a Win32.FileHandle is never overlapped. SYS-0012 rejects FILE_FLAG_OVERLAPPED on open, and SYS-0013's FileDescriptor bridging does not lend a handle if it's overlapped. A future asynchronous handle API should instead apply the flag implicitly like Win32.DirectoryHandle (SYS-0012) does with FILE_FLAG_BACKUP_SEMANTICS and withhold operations that fail.

init?(unsafelyAdopting:) does not check for the overlapped flag. It's @unsafe because the caller vouches that the handle is valid, owned, and synchronous. The safe FileDescriptor.withWin32HandleIfAvailable(_:) can't take a caller's word for it, so it makes the check. Code that owns an overlapped handle should keep it as a HANDLE until an asynchronous type exists.

Conformances

Win32.FileHandle conforms to Sendable and nothing else. Sendable is safe on a uniquely owned handle, and Windows permits concurrent use of one handle from multiple threads, though it serializes I/O on a synchronous handle (see SYS-0014). Hashable may be revisited when noncopyable hashed collections are more widely available.

Source compatibility

This proposal is additive and source-compatible with existing code.

ABI compatibility

This proposal is additive and ABI-compatible with existing code.

Implications on adoption

Win32.FileHandle sits behind #if os(Windows), so cross-platform callers must guard their uses. The @safe and @unsafe attributes require Swift 6.2, so System will need to raise its toolchain floor.

Future directions

SYS-0010 lists the rest of this series and the stages that could follow it.

  • Asynchronous I/O. An overlapped handle could get its own type as Synchronous handles only describes, with async versions of the I/O in SYS-0014.
  • A borrowed handle type. A copyable, nonescapable handle view would let an API accept any handle kind, and could represent handles no Swift value owns, such as the standard handles from GetStdHandle. Blocked as of Swift 6.4 since an owned let handle that has been viewed can't be consumed with handle.close() afterward, even after the view's last use.
  • Opt-in close checking. If Swift adds must-consume types, a wrapper could make a forgotten close() a compile error. Until then, an application that wants every close to be explicit can wrap Win32.FileHandle in a type whose deinit traps.

Alternatives considered

Build on FileDescriptor instead

Add the Win32 operations in this series to FileDescriptor on Windows, recovering the handle internally, so System keeps one file type.

Rejected because:

  • It would keep the descriptor's copyable model.
  • Every operation would bypass the descriptor's translation mode, disagreeing with FileDescriptor.read(into:) on a text-mode descriptor.
  • It can't fix the pread deviation, where matching POSIX semantics would take three calls (seek, read, restore) and not be atomic against other users of the handle.

A copyable value type with a manual close()

Model Win32.FileHandle on FileDescriptor: a @frozen copyable struct conforming to RawRepresentable, with a non-consuming close(). This matches System's house style and allows handles in ordinary collections.

Rejected because:

  • Double close, use after close, and leaks all stay expressible. The primary type should offer lifetime safety.
  • The decision is asymmetric. A copyable borrowed view can be added to a noncopyable type later, but ownership cannot be added to a copyable type.

A copyable Win32.RawFileHandle beneath Win32.FileHandle

Keep Win32.FileHandle as the owner, but have it store a copyable Win32.RawFileHandle and forward its operations to it. Code that can't hold a borrow would still get a type more specific than HANDLE.

Rejected because:

  • If a programmer error closes the copyable handle early, Windows can reuse the value for any new object, including an overlapped handle, on which the synchronous I/O in SYS-0014 is memory-unsafe. Its operations would therefore have to be @unsafe, just like calling Win32 with a HANDLE directly.
  • It doubles the API surface without improving the escape hatch. Code that can't use Win32.FileHandle is typically giving the handle to raw Win32 or another library, which uses HANDLE anyway. Unlike FileDescriptor, there's no existing Win32.RawFileHandle API to interop with.
  • Where a handle is a parameter, borrowing already gives it a distinct type along with lifetime safety, and Future directions describes a borrowed handle type for the cases borrowing can't express.

A class with a closing deinit

Make Win32.FileHandle a class whose deinit closes the handle, preventing leaks without a noncopyable type.

Rejected because:

  • It adds ARC and an allocation to a type that is morally a machine word. A ~Copyable struct prevents leaks with neither cost.
  • Every copy of the reference gains the ability to close.

A deinit that traps instead of closing

As prior art, NIO's SystemFileHandle calls fatalError if it's dropped while open, and SE-0528's Continuation traps if it's deinitialized without being resumed. A trap forces the programmer to choose where the handle closes.

Rejected because:

  • Unlike the cases above, Win32.FileHandle.deinit can close the handle correctly since CloseHandle is synchronous, and a trap there wouldn't rule out bugs in safe code. NIO's deinit can't await its async close(), and Continuation.deinit has no result to resume with, so their traps turn a silent leak or hang into a crash.
  • A trapping deinit would make the client close the handle on every error path in the scope. Since defer can't consume a noncopyable value, the owner would need a do/catch that closes the handle and rethrows, or an Optional that defer can take(). Otherwise, a throw between the open and close would abort the process before any caller could catch it. An application could choose that fatalError policy using a wrapper type, but a low-level library like System should not impose it.
  • An owner that needs a precise close point can still call close().
  • deinit discards errors, but they're errors a caller can't reasonably recover from. Every known CloseHandle failure is an ownership violation that already traps in debug builds (see Lifetime and error handling).
  • Some owners wouldn't be able to close a trapping handle without wrapping it. A class or an InlineArray would need an Optional, since neither can consume a stored handle. A handle shared with a task group's children would need a box such as Mutex, since a value they capture can't be consumed afterward.

Scoped withOpen and closeAfter entry points

withOpen(path, access:...) { file in } and a consuming func closeAfter(_:) would make the close impossible to forget, as FileDescriptor.closeAfter(_:) does today.

Rejected because:

  • Noncopyable ownership already guarantees that, so the scoped forms only add more API surface (a withOpen per opening function in SYS-0012, each restating its parameters and doc comment).
  • Typed throws would then double that surface with the overload pattern withLock(byteRange:mode:_:) uses in SYS-0016. Separate statements already give each call its own error type.

Appendix

Swift API to C mappings

Swift C
Win32.FileHandle HANDLE
Win32.FileHandle.close() CloseHandle
Win32.FileHandle.deinit CloseHandle, error discarded
Win32.FileHandle.relinquish() none; gives up ownership of the HANDLE

Testing was performed on an ARM64 Windows 11 VM (build 22631).

2 Likes

+1 on adding the type in general but leaving my usual remark here about using deinit for anything that is not memory. I continue to think that it's not the right tool for the job. It makes it very hard to reason about the lifetime of resources since it isn't obvious from code reading when exactly a deinit is called. I know that @johannesweiss keeps a list of problems that he has encountered with deinit based clean up.

3 Likes

I didn't really read this carefully, but I'm a -0.5 on this as is. Primarily, because this type is both useful and not very useful.

I think that the core idea beneath it is valid and important: we need a non-copyable handle type representation. The problem is that FileHandle is largely misleading and meaningless on Windows. I'd describe it more as: Windows has ~3 types of handles for OS level resources:

  1. FILE * handle (ucrt managed)
  2. SOCKET handles (WinSock managed)
  3. HANDLE handles (Kernel managed)

FILE * handles are psuedo handles, a mapping managed entirely in userspace, by ucrt. This is possible to convert to a HANDLE handle via _get_osfhandle (_open_osfhandle being the inverse mapping a HANDLE to FILE *). However, under the API surface, it is still a HANDLE to a kernel object.

SOCKET handles are WinSock managed handles that represent a socket in the BSD Socket API. This is not interchangable with a kernel HANDLE and must remain fully isolated.

HANDLE values are opaque kernel handles that can represent any type of kernel resource, a file, a mutex, a memory mapping, or anything else. The way that you manage and use them is what exposes their differences.

Now, a FileHandle to represent the differences in the type system is very helpful, but I think that it might be valuable to have that be a higher level representation, built upon a lower construct which is the non-copyable handle representation.

There are a large number of UI primitives which are also handles: bitmaps (HBITMAP), drawing contexts (HDC), strings (HSTRING), etc. These all have similar semantics as handles: you acquire the resource and relinquish the resource with associated operations; the handle itself can be passed along freely.

Again, referring back to Swift/Win32, I would present this as a possible representation:

This allows us to have the concrete handle types, noting that while it is extensible for the UI layer, it will collapse down the OS layer as described above: a pipe handle, a file handle, a mutex handle etc all yield HANDLE. This is where a type like FileHandle to refine the semantics around a given handle is going to be helpful, but it should not be the core type itself.

1 Like

I can see the arguments against an implicit close in deinit, but with noncopyable types, the lifetime of the resource is more clear than, say, a class with ARC. Code that needs a precise close point can still call close(), so deinit is mostly a safeguard for early exits (like try propagating an error).

The alternative would be a trapping deinit, but since defer can't consume a noncopyable value, any code that throws between the open and the close would need a do/catch or an Optional, and forgetting to use these patterns means a crash. I don't think there's an ergonomic way to require an explicit close until Swift supports must-consume types, and then a wrapper could opt in.

I personally find this a weak argument for using deinit to manage resources but rather an argument for defer being able to consume noncopyable values.

4 Likes

I was envisioning a System where we have:

  1. CRT descriptors (UCRT managed) - FileDescriptor, which already wraps the int descriptor that _get_osfhandle maps to a HANDLE.
  2. SOCKET handles (WinSock managed) - a socket type that offers the socket API and closes with closesocket.
  3. HANDLE handles (Kernel managed) - a type per kind of object. FileHandle is the non-overlapped type for what CreateFileW returns. Events, mutexes, processes, etc. would get their own types rather than going through FileHandle.

With this ...Handle pattern, we can also have more specialized types like DirectoryHandle (proposed in SYS-0012), which only opens directories and could offer directory-specific API like enumeration. We could then offer a failable conversion from FileHandle to DirectoryHandle that checks that the HANDLE is a directory and returns nil otherwise.

I think having a basic protocol that these specific, noncopyable types conform to could be useful, but it can also be added after the fact. The same goes for a generic owner like ManagedHandle. For System, though, the shared part of such a protocol would be small and would only deduplicate adopting, relinquishing, and closing (plus duplication and inheritance for the HANDLE types). For handles that aren't owned, like those from GetStdHandle, I'd rather use a separate borrowed type than a referencing mode within a type like ManagedHandle.

I agree it's also an argument for defer being able to consume noncopyable values, and I'd like to see that happen. But even so, it wouldn't cover every case. For example:

func openDiskFile(_ path: FilePath) throws -> Win32.FileHandle {
  let handle = try Win32.FileHandle.open(path, access: .readData)
  guard try handle.fileType() == .disk else { throw NotADiskFile() }
  return handle
}

A consuming defer still wouldn't work here since return handle already consumes the handle (unless defer skipped values that were already consumed, which is what a closing deinit already does). So if we wanted to force a client not to rely on deinit, e.g. via trapping, they would still need to write:

let handle = try Win32.FileHandle.open(path, access: .readData)
do {
  guard try handle.fileType() == .disk else { throw NotADiskFile() }
} catch {
  try? handle.close()
  throw error
}
return handle

which is not so ergonomic. In cases where a consuming defer would work, defer { try? handle.close() } is exactly the same close as the deinit, just with extra code. An application that wants every close written out like this can still opt in with a wrapper whose deinit traps.

I think that this is where I get tripped up. The HANDLE type is not differentiated. At the same time, the API needs to be convertible to the type-erased view as that is the interoperability boundary for the system APIs. The result is the confusion that arises.

Another point that becomes confusing is that the same APIs support both overlapped and non-overlapped operations. So having a type system layer on top would require either wrapping the system APIs entirely to separate them, or we would need to allow translation between the two, which negates the value of the type system representation.

Maybe I’m not seeing the full vision for this immediately. Using the type system to avoid the incorrect behavior is of course a huge win if possible (I know that I tried to do something in COM interop for avoiding incorrect parameters for some of the API surface).

For Win32 calls System doesn't wrap, the type-erased view is each handle's unsafeRawHandle. This is "safe" to vend in the sense that operations on the HANDLE can't change the guarantees of the owning wrapper (like whether it's overlapped, or a directory). But overall the API is still @unsafe because misusing the HANDLE can break ownership.

What's missing is a safe way for generic code to take any handle. A shared protocol that requires var unsafeRawHandle would work today. A nonescapable borrowed handle would be great for unowned handles (like those from GetStdHandle), but that type is blocked on a compiler limitation. (Namely, once a let handle lends a handle.borrowedHandle view, a later handle.close() fails to compile, even after the borrowed view exits scope.)

The same C functions are used by several types, but each type only offers the wrappers that are safe for its kind and mode. So it's more like your first option. When switching between overlapped and synchronous, we'd reopen the file as a new file object, so the original handle's type stays correct.

Operations that apply to several kinds of handles, like duplication and reopening, are offered on each type they work on, so a client doesn't need to convert a DirectoryHandle to a FileHandle to use them.

Here's how I could imagine it:

Types

Type Opens with Guarantees
FileHandle CreateFileW A synchronous file, device, or pipe (opening rejects FILE_FLAG_OVERLAPPED)
DirectoryHandle CreateFileW Always a directory and never overlapped (open adds FILE_FLAG_BACKUP_SEMANTICS and checks the result)
AsyncFileHandle (future) CreateFileW Always overlapped and bound to a completion port
EventHandle, ProcessHandle, ... (future) CreateEventW, CreateProcessW, ... The kind of object its create function returns
SocketHandle (future) WSASocketW Closes with closesocket, never converts to a HANDLE owner
FileDescriptor (existing) FileDescriptor.open A CRT descriptor (which may wrap an overlapped handle, such as a socket)

Operations

Operation FileHandle DirectoryHandle AsyncFileHandle
Read, write, byte-range locks āœ“ — async
Seek āœ“ — —
Size, resize, flush, file type āœ“ — āœ“
Reopen, volume info, final path āœ“ āœ“ āœ“
Duplicate, inheritance āœ“ āœ“ —
Enumeration (future) — āœ“ —

AsyncFileHandle withholds duplication and inheritance, since I/O through any copy of a handle bound to a completion port is reported to that port. Of the operations listed, EventHandle and ProcessHandle would only offer duplication and inheritance, and SocketHandle wouldn't offer any of them (it would have its own WSA/socket APIs).

Conversions

From To API Result
Any HANDLE owner HANDLE unsafeRawHandle Borrowed, for Win32 calls System doesn't wrap
HANDLE Any HANDLE owner init?(unsafelyAdopting:) The caller vouches for the kind
FileHandle DirectoryHandle init?(duplicating:) (future) A duplicate if it's a directory, otherwise nil; the original is borrowed for the init
DirectoryHandle FileHandle None It would bring back read and write, which fail on a directory
FileHandle or AsyncFileHandle The other init(reopening:) (future) A new file object in the other mode; the original stays as-is
FileDescriptor FileHandle withWin32HandleIfAvailable(_:) Borrowed, nil if there's no handle or it's overlapped
FileHandle FileDescriptor init(adopting:) Consumes the handle

I guess that this laid out is a bit better, but I still wonder if we might be within a short distance of a safe API. I take it that the current shape is not one that blocks the nonescapable borrowed handle approach in the future?

Right, the current shape doesn't block the nonescapable borrowed handle approach in the future. Thinking about it though, we could actually offer one today as long as it only supports a with-style closure, e.g.

extension Win32 {
  public protocol Handle: ~Copyable, ~Escapable {
    @unsafe var unsafeRawHandle: HANDLE { get }
  }

  /// A copyable, nonescapable view of any handle.
  public struct BorrowedHandle: Handle, ~Escapable {
    @unsafe public var unsafeRawHandle: HANDLE { get }

    /// For handles no Swift value owns, like those from `GetStdHandle`.
    @unsafe @_lifetime(immortal)
    public init(unsafeBorrowing raw: HANDLE)
  }
}

extension Win32.Handle where Self: ~Copyable {
  public func withBorrowedHandle<R: ~Copyable, E: Error>(
    _ body: (Win32.BorrowedHandle) throws(E) -> R
  ) throws(E) -> R
}

A function can then take any handle without generics, and this doesn't hit the compiler issue when closing the handle outside of the with-scope:

func inspect(_ handle: Win32.BorrowedHandle) { ... }

let handle = try Win32.FileHandle.open(path, access: .readData)
handle.withBorrowedHandle { inspect($0) }
try handle.close() // OK

We could still add a var borrowedHandle in the future when the compiler issue is fixed. Does that sound good to you?

Yeah, this sounds better to me. One thing that still bothers me about the shape of the APIs is the static factory methods. Why prefer those to initializers? i.e. Win32.FileHandle.open(_:access:) -> Self vs Win32.FileHandle.init(opening:access:).

1 Like

The rule the proposals currently follow is that creating something new from a path or name gets a static verb (open, openOrCreate, maybe a future pipe); whereas conversion between types or taking ownership of a raw value is an initializer on the destination type, per API guidelines.

Also, sometimes the static methods don't return Self, like in the case of Win32.FileHandle.openOrCreate, which corresponds to CreateFileW with OPEN_ALWAYS or CREATE_ALWAYS and returns an OpenOrCreateResult that contains let existed: Bool. If we wanted this as an initializer it would have to use an inout parameter. I think existed works better in the result type.

And lastly the current FileDescriptor precedent has open, pipe, etc. as static methods, too.

As @FranzBusch pointed out, using deinit for directly observable effects and other non-memory resources is not a good idea.

It is crucial to know when a close happens exactly and also in what order, deinit obfuscates this at best and makes it impossible to really control. For humans (and agents alike) it is a feature if there is a syntactical trace of when exactly a close happens.
Finally, close (at least on UNIX) can block. Furthermore, handles/fds are precious resources which means that in many cases you want to be sure they're closed early.

When using deinit to drop these resources, really innocuous-looking changes can completely change the behaviour of programs in meaningful ways which is of course to be avoided.

defer is the right tool for the job, let's make it possible to use defer for it.

4 Likes

I am also on the record as disliking RAII, but could explicit close be implemented with a consuming func that just assigns self to _?

1 Like

For a noncopyable local, deinit runs at the end of its lexical scope, not after its last use, so it closes at the same point as a defer written right after the open. It blocks the exact same way the defer would, too. To close earlier or leave a syntactic trace, clients should use try handle.close(), which is also the only way to see an error.

A local handle's close point only moves when its ownership changes. If an added code path consumes the handle, the original path does close earlier, where they rejoin. But an explicit close() at the end keeps the close point from moving, since any earlier consume (even an implicit one) is then a compile error:

let handle = try Win32.FileHandle.open(path, access: .readData)
if shouldClose { try handle.close() }
try handle.close() // error: 'handle' consumed more than once

A consuming defer would be great, and the closing deinit as proposed doesn't block that in any way. Clients could write defer { try? handle.close() } as soon as the language allows it.

But fixing defer still wouldn't remove the need for deinit, since code that returns the handle (like openDiskFile from my earlier post) or calls close() to check for an error can't also consume it in a defer.

1 Like

We could, but then an explicit try handle.close() would discard the error, too.

Hm, that seems like an even stronger reason not to model this with RAII. If it were possible to declare a private deinit, then we could enforce that the client calls consuming func close() throws with an explicit try.

1 Like

From experience having worked with ~Copyable types a lot over the past two years this is incredibly difficult to reason about. Looking at all lexical scopes especially with nested control-flow and closures to understand when a resource gets closed is hard. When reviewing PRs that's even harder because you might not even see where the resource was created in a diff but the change might subtly change when the deinit runs. I personally continue to be of the strong opinion that resource management outside of memory with deinits is bad and will lead to hard-to-maintain and reason-about code.

Since we had this discussion multiple times now, it might be worth asking the LSG for guidance here so that we can settle this.

2 Likes

I am not particularly familiar with file handles on Windows and how they compare/contrast with file descriptors on *nix platforms. One concern with this approach, namely one type with escape hatches to raw C imports, is that the escape hatch "escapes" from both the static lifetime and type safety and other forms of safety that System typically layers on top of the syscalls.