Should noncopyable types release system resources in `deinit`?

I'd like to summarize the arguments here to help us come closer to a decision.

Linear types (~Deinitable) would make a forgotten close() a compile error, and many of us prefer compile-time enforcement (#12, #17, #21, #25, #39). However, linear types aren't guaranteed to happen, or to happen any time soon (#14, #15). Agreeing that System's new types are best expressed as linear types could help prioritize the feature (#14).

Narrowing the question

Let's assume that ~Deinitable isn't available yet. A noncopyable resource type's deinit can then trap or close. (A deinit that does neither silently leaks resources, so we'll ignore that case.) Either option can still trap on an ownership violation, such as a close that fails with EBADF (pitch #31).

How do we decide whether Trap or Close is better?

Let's assume our type has a consuming, throwing close() that reports any error from the underlying close function (e.g. close or CloseHandle). Using this close(), the two options differ only when a handle reaches its deinit, where Trap crashes and Close closes the handle. Any code that works under Trap, including its error handling, would behave and perform identically under Close.

So the better option depends on what developers will write, not what they can write. Most benefits of Trap come from the fact that a developer or library author must write an explicit close() on every path, or else the app/server will crash.

Each argument below is followed by a counterargument. Expand the summaries to see more details and sources.

Arguments for Trap


Trap: Explicit close() calls leave a syntactic trace that makes lifetimes easier to reason about.
  • Swift requires try and await so that readers can see the effects. A close can block, fail, and change kernel state, so a close deserves the same visibility (pitch #22).
  • Handles are limited, so programs need to control exactly when they're released (#19, pitch #14).
  • An innocuous-looking change can move an implicit close. The change's diff might not show where the handle was opened (#17, pitch #21).

For example, suppose only one branch closes the handle. On the other path, deinit closes the handle where the branches rejoin (#38, #43):

let handle = try Win32.FileHandle.open(...)
if shouldClose {
  try? handle.close()
}
// If `shouldClose` is false, `deinit` closes `handle` here, before `finish()`
finish()
Close: Code that needs a precise close point can still call close(), and the compiler then keeps that point from moving.
  • An explicit close() at the end of a scope makes any earlier consume a compile error (pitch #16).
  • A handle that's never consumed closes where a defer { try? handle.close() } written right after its open would run, if defer could consume the handle (pitch #16).
  • Today, a handle consumed on only one path closes where the paths rejoin. John says that's intended (#42), and Alejandro calls it a bug (#41). Kavon offered to move that close to the end of the scope in a new move checker (#46).
  • Unlike a missing try, a missing close() isn't a compile error with either option. Trap only catches a missing close() on paths that run.

Trap: Requiring an explicit close() encourages developers to perform error handling.
  • The developer must write try handle.close() or acknowledge the discarded error with try? handle.close().
  • Swift doesn't let you omit try just because you plan to ignore the error (#22).
  • A closing deinit always loses the error (pitch #21).
  • On some platforms, close can report a deferred write error (#6). A program could log that error or show it to the user (#34).
Close: A program can rarely act on a close error, and developers who wish to perform error handling will do so anyway.
  • Linux, the BSDs, and Darwin release the descriptor even when close fails, so a failed close shouldn't be retried (#13, #37).
  • Many closes can't fail in any meaningful or actionable way; Trap adds complexity and noise for these cases (#23, #31).
  • To catch lost writes, a program has to call fsync or FlushFileBuffers before closing. Neither option enforces those calls (#13).
  • A developer who can't act on the error will write try? handle.close() at the end of the scope. That try? is equivalent to a closing deinit, except that the deinit also avoids accidental crashes on error paths (pitch #7).

Trap: The ecosystem should follow one pattern for non-memory resources, and some resources require trapping in deinit.
  • The pattern should also cover clients, requests, and queries (#17).
  • Many resources have async or throwing cleanup that deinit can't express (#17, #38).
  • Some resources must be released from a specific isolation. Isolated deinits likely won't come to noncopyable types (#12).
  • RAII should only be used for resources whose release can't fail except by programmer error (pitch #24). The release also shouldn't block for long and should either be opt-in or have no observable timing.
Close: One rule fits every resource: trap only where deinit can't release the resource correctly.
  • Under that rule, Continuation and NIO's async file handle keep their traps. Windows handles and Mach ports close in deinit (#7).
  • The choice should depend on the resource. Many resources can be released without reporting real effects to the caller (#20).
  • SE-0390's motivating example is a noncopyable FileDescriptor whose deinit closes the descriptor (#36). Franz and others repeatedly objected to such resource examples in the ~Copyable proposals (#38).

Trap: Automatic disposal should be an opt-in wrapper, not the default.
  • The default should be that the code holding a handle is responsible for closing it. Delegating that responsibility, for example with a wrapper like RAII<FileDescriptor>, should be an explicit choice (pitch #21).
Close: A wrapper can't opt a developer out of crashes in someone else's code.
  • An app's wrapper doesn't cover handles that a library opens or accepts. Whether those handles trap or close is the library author's choice, not the app developer's. If Trap is the default, a missed close() in a library can crash the app, and only the library's author can fix the crash.
  • With Close as the default, the opt-in goes the other way. SYS-0011 suggests a trapping wrapper for those who want it, and only code that adopts the wrapper is affected.

Trap: Trapping keeps our options open, since relaxing a trap later is not source-breaking.
  • A closing deinit can't start trapping later without crashing clients that rely on the implicit close.
  • Code written for Trap already closes on every path like a future ~Deinitable type would require.
Close: Adopting ~Deinitable later would be source-breaking with either option.
  • A ~Deinitable type would no longer fit APIs that take ~Copyable types, unless they're generalized (#14). Those APIs include Optional, which Trap's workarounds use.
  • Aviva suggested staging in ~Deinitable while keeping the existing deinit for code that hasn't migrated (#15). That approach works whether the deinit traps or closes.
  • Even once ~Deinitable exists, a read-only file stream may not need it, since its close can't fail in any meaningful way (#23).

Arguments for Close


Close: Crashing the app/server on a forgotten close() is a poor user experience that can be avoided.
  • A crash may lose other work or user data that's not related to the crashing handle.
  • Certain user configurations may lead to constant crashes and inability to use the app or service.
  • These crashes would mainly happen in untested error paths, so many would first happen in production (#7, #39).
Trap: Most forgotten closes are caught during development.
  • A forgotten close() on a common path crashes as soon as that code path runs (#17).
  • Crash reports help the developer or library author fix the rest.

Close: Closing in deinit fits Swift's goal of progressive disclosure: simple uses stay simple, and developers can add explicit closes and error handling as they need them.
  • The swift.org home page says Swift's "progressive disclosure allows you to pick up the language quickly, taking advantage of power-user features as you need them."
  • With Close, a simple use can open a handle and let deinit close it, even on error paths.
  • Code that needs the close error can call close(). Code that needs durability can call fsync or FlushFileBuffers first. Code that wants every close to be explicit can adopt a trapping wrapper.
  • With Trap, even the simplest use must close the handle explicitly on every path, including error paths. John notes that explicitly destroying a value on every path is "really laborious," even with compiler checking (#20).
Trap: Progressive disclosure shouldn't hide effects. Swift requires try and await even in simple code.
  • The compiler knows which calls throw or suspend, but Swift still requires the markers so that readers can follow the control flow (pitch #22).

Close: With Trap, seemingly innocuous changes can crash the process if errors aren't handled within scope. Handling those errors locally takes cumbersome do/catch blocks that close the handle and rethrow.

In a function that already throws, adding a try between an open and a close looks harmless (#24):

 let handle = try Win32.FileHandle.open(...)
+try someMethod()
 try handle.close()

But if someMethod() throws, the process now traps instead of propagating the error. The diff doesn't show the new crash.

To propagate the error, Trap requires a local do/catch that closes the handle and rethrows. (Wrapping the handle in an Optional so defer can take() it is another workaround.)

let handle = try Win32.FileHandle.open(...)
do {
  try someMethod()
} catch {
  try? handle.close() // Ignores the error anyway
  throw error
}
try handle.close()
Trap: Scoped helpers and future language features could close the handle on error paths.
  • Franz agrees the added-try problem should be solved, but not with implicit closing (#30).
  • Scoped helpers would eliminate the do/catch in most cases (#2, #21).
  • David suggests the clutter is "tertiary to correctness and clarity" (#21).
  • The pitched at-most-once functions feature should make a consuming defer possible (#3).
  • A with syntax, like Python's context managers, could cover remaining use cases (#30, #38).
Close: Scoped helpers and with blocks can't cover a handle that a function returns, and they must turn errors from the body and the close into one thrown error.
  • A function can't return a handle that a helper or with block manages (#35).
  • A consuming defer can't be combined with return handle or an explicit try handle.close(). The handle would be consumed twice, which doesn't compile (pitch #16).
  • Only a defer that skips an already-consumed handle would work. A closing deinit already does that (pitch #7).
  • Swift doesn't let a defer throw, so a consuming defer couldn't propagate the close error (#23).
  • A helper's closure must also carry every effect, including the thrown error type (#2, #17). David suggests untyped throws for the error type (#4).
  • When the body and the close both throw, a helper or with block can only throw one error. System's FileDescriptor.closeAfter(_:) and Franz's with sketch both drop the close error (#30), just as a closing deinit does.
Trap: In the future, ~Deinitable could cover the handles that helpers can't.
  • Franz suggests eventually adopting both context managers and ~Deinitable (#38). Context managers would handle most code. ~Deinitable would give compile-time checks to code that wants manual control.

Close: Language limitations prevent some owners from closing a handle explicitly.
  • A class or InlineArray can't consume a stored handle (pitch #1).
  • A handle captured by a task can't be consumed afterward (pitch #1).
  • A let handle that lends a nonescapable view can't be consumed afterward either (#16).
  • With Trap, these owners need a workaround, like an Optional or a Mutex box. Otherwise, the process crashes.
Trap: Swift should lift those limitations instead of designing APIs around them.
  • Franz would recommend the trapping pattern more broadly once Swift has call-once closures and task groups that support ~Copyable values (#17).
  • Franz argued the same about defer: its limitation is an argument for a consuming defer, not for deinit (pitch #5).

I've tried to include the main arguments from this discussion and the pitch thread, plus a few that hadn't come up yet. Please feel free to pick them apart or add your own!

2 Likes