Made File.Handle a non-copyable struct in the library to adopt Swift 6.4.

This commit is contained in:
2026-09-18 12:18:05 +02:00
parent 95ae01f97a
commit 07c4fce37b
5 changed files with 66 additions and 30 deletions
+13 -10
View File
@@ -1,11 +1,13 @@
internal import CPlaydate internal import CPlaydate
extension File { extension File {
/// An open file. Wraps `SDFile`. The file is closed on deinit if it has /// An open file. Wraps `SDFile`. The file is closed when the handle goes
/// not been closed explicitly. /// out of scope, unless it was closed explicitly with `close()`.
public final class Handle { ///
/// The handle is non-copyable: it has a single owner, so it cannot be
/// used after `close()` and no heap allocation backs it.
public struct Handle: ~Copyable {
let pointer: UnsafeMutableRawPointer let pointer: UnsafeMutableRawPointer
private var isClosed = false
/// Opens the file at `path`. /// Opens the file at `path`.
public init(path: String, mode: Options) throws(PlaydateError) { public init(path: String, mode: Options) throws(PlaydateError) {
@@ -17,15 +19,16 @@ extension File {
} }
deinit { deinit {
if !isClosed {
_ = fileAPI.pointee.close.unsafelyUnwrapped(pointer) _ = fileAPI.pointee.close.unsafelyUnwrapped(pointer)
} }
}
/// Closes the file. Further operations are invalid. /// Closes the file, consuming the handle.
public func close() throws(PlaydateError) { // `@export(interface)` lets `discard` compile in Embedded Swift with
guard !isClosed else { return } // the 6.4 release toolchain; later toolchains accept it without.
isClosed = true @export(interface)
public consuming func close() throws(PlaydateError) {
let pointer = self.pointer
discard self
if fileAPI.pointee.close.unsafelyUnwrapped(pointer) != 0 { throw lastFileError() } if fileAPI.pointee.close.unsafelyUnwrapped(pointer) != 0 { throw lastFileError() }
} }
@@ -8,8 +8,10 @@ extension Graphics {
/// Wraps `LCDStreamPlayer`. /// Wraps `LCDStreamPlayer`.
public final class StreamPlayer { public final class StreamPlayer {
let pointer: OpaquePointer let pointer: OpaquePointer
/// Retains the active source so it outlives the stream. /// Retains the active source so it outlives the stream. A file
/// handle is owned outright, as it cannot be shared.
private var retainedSource: AnyObject? private var retainedSource: AnyObject?
private var retainedFile: File.Handle?
public init() { public init() {
pointer = streamAPI.pointee.newPlayer.unsafelyUnwrapped().unsafelyUnwrapped pointer = streamAPI.pointee.newPlayer.unsafelyUnwrapped().unsafelyUnwrapped
@@ -24,22 +26,26 @@ extension Graphics {
streamAPI.pointee.setBufferSize.unsafelyUnwrapped(pointer, Int32(video), Int32(audio)) streamAPI.pointee.setBufferSize.unsafelyUnwrapped(pointer, Int32(video), Int32(audio))
} }
/// Streams from an open file. /// Streams from an open file. The stream takes ownership of the
public func setFile(_ file: File.Handle) { /// handle and closes it when the source changes or the stream is freed.
retainedSource = file public func setFile(_ file: consuming File.Handle) {
streamAPI.pointee.setFile.unsafelyUnwrapped(pointer, file.pointer) streamAPI.pointee.setFile.unsafelyUnwrapped(pointer, file.pointer)
retainedFile = consume file
retainedSource = nil
} }
/// Streams from an HTTP connection. /// Streams from an HTTP connection.
public func setHTTPConnection(_ connection: Network.HTTPConnection) { public func setHTTPConnection(_ connection: Network.HTTPConnection) {
retainedSource = connection
streamAPI.pointee.setHTTPConnection.unsafelyUnwrapped(pointer, connection.pointer) streamAPI.pointee.setHTTPConnection.unsafelyUnwrapped(pointer, connection.pointer)
retainedSource = connection
retainedFile = nil
} }
/// Streams from a TCP connection. /// Streams from a TCP connection.
public func setTCPConnection(_ connection: Network.TCPConnection) { public func setTCPConnection(_ connection: Network.TCPConnection) {
retainedSource = connection
streamAPI.pointee.setTCPConnection.unsafelyUnwrapped(pointer, connection.pointer) streamAPI.pointee.setTCPConnection.unsafelyUnwrapped(pointer, connection.pointer)
retainedSource = connection
retainedFile = nil
} }
/// The player used for the stream's audio track. Owned by the stream. /// The player used for the stream's audio track. Owned by the stream.
+6 -12
View File
@@ -118,28 +118,22 @@ extension JSON {
} }
/// Decodes JSON read from an open file into a `Value` tree. /// Decodes JSON read from an open file into a `Value` tree.
public static func decode(file: File.Handle) throws(PlaydateError) -> Value { public static func decode(file: borrowing File.Handle) throws(PlaydateError) -> Value {
let context = DecodeContext() let context = DecodeContext()
var decoder = makeDecoder(context: Unmanaged.passUnretained(context)) var decoder = makeDecoder(context: Unmanaged.passUnretained(context))
var reader = json_reader() var reader = json_reader()
reader.userdata = Unmanaged.passUnretained(file).toOpaque() // The handle is borrowed for the whole call, so its `SDFile` stays
// open while the decoder reads through it.
reader.userdata = file.pointer
reader.read = { userdata, buffer, size in reader.read = { userdata, buffer, size in
guard let userdata, let buffer else { return -1 } guard let userdata, let buffer else { return -1 }
let file = Unmanaged<File.Handle>.fromOpaque(userdata).takeUnretainedValue() let count = fileAPI.pointee.read.unsafelyUnwrapped(userdata, buffer, UInt32(size))
var destination = UnsafeMutableBufferPointer(start: buffer, count: Int(size)).mutableSpan return count > 0 ? count : -1
do {
let count = try file.read(into: &destination)
return count > 0 ? Int32(count) : -1
} catch {
return -1
}
} }
var outval = json_value() var outval = json_value()
let ok = withExtendedLifetime(context) { let ok = withExtendedLifetime(context) {
withExtendedLifetime(file) {
jsonAPI.pointee.decode.unsafelyUnwrapped(&decoder, reader, &outval) != 0 jsonAPI.pointee.decode.unsafelyUnwrapped(&decoder, reader, &outval) != 0
} }
}
guard ok else { guard ok else {
// A completed root container may already have been written to // A completed root container may already have been written to
// outval before the failure; consume it so its box is not leaked. // outval before the failure; consume it so its box is not leaked.
+13
View File
@@ -422,6 +422,19 @@ enum Mock {
private static func installJSON() { private static func installJSON() {
jsonAPI.initialize(to: playdate_json()) jsonAPI.initialize(to: playdate_json())
// Pulls one chunk through the reader, as the OS would, and decodes
// it as `null`.
jsonAPI.pointee.decode = { _, reader, outval in
var buffer = [UInt8](repeating: 0, count: 16)
let count = buffer.withUnsafeMutableBufferPointer { buffer in
reader.read?(reader.userdata, buffer.baseAddress, Int32(buffer.count)) ?? -1
}
Mock.record("decode(\(count))")
outval?.pointee = json_value()
outval?.pointee.type = CChar(kJSONNull.rawValue)
return 1
}
jsonAPI.pointee.decodeString = { decoder, _, outval in jsonAPI.pointee.decodeString = { decoder, _, outval in
guard let decoder else { return 0 } guard let decoder else { return 0 }
+20
View File
@@ -345,6 +345,26 @@ struct WrapperTests {
#expect(Mock.eventCount("close") == 1) #expect(Mock.eventCount("close") == 1)
} }
@Test func jsonDecodeFileReadsThroughTheHandleAndClosesIt() throws {
Mock.fileReadLimit = 5
let value = try JSON.decodeFile(path: "save.json")
guard case .null = value else {
Issue.record("expected .null, got \(value)")
return
}
#expect(Mock.events.contains("decode(5)"))
#expect(Mock.eventCount("close") == 1)
}
@Test func fileHandleClosesWhenItGoesOutOfScope() throws {
do {
let handle = try File.Handle(path: "save.dat", mode: .write)
_ = try handle.write([1])
#expect(Mock.eventCount("close") == 0)
}
#expect(Mock.eventCount("close") == 1)
}
@Test func fileHandleReadLengthReturnsOnlyTheBytesRead() throws { @Test func fileHandleReadLengthReturnsOnlyTheBytesRead() throws {
let handle = try File.Handle(path: "save.dat", mode: [.read, .readData]) let handle = try File.Handle(path: "save.dat", mode: [.read, .readData])