1
0
Fork 0

Generate boxed-record memory management and interface requirements

Boxed records (C4): resolve each record's copy/free function from the GIR
copy-function/free-function attribute, else its own ref/copy and unref/free
method, and render init(retaining:) plus an isolated deinit. The deinit is
isolated because the package's default MainActor isolation makes accessing the
non-Sendable pointer from a nonisolated deinit a hard error. The registry uses
the same resolution so transfer=none returns copy instead of adopting a
borrowed pointer. This supersedes the earlier deferral: the hypothesised
OpaquePointer/Sendable barriers either did not hold or were solvable.

Interfaces (C5): plan each interface method through planMethod and render it as
a protocol requirement (no body); the conforming class supplies the C call.
This commit is contained in:
Brendan Szymanski 2026-07-17 21:04:37 -04:00
parent 8698d48e1c
commit 164f8b7b9a
7 changed files with 320 additions and 14 deletions

View file

@ -443,12 +443,17 @@ public struct InterfacePlan: Equatable, Sendable {
public let cType: String
public let prereqs: [String]
public let getTypeFunction: String?
/// The interface's instance methods, rendered as protocol requirements.
/// The implementing class supplies each method body (the C symbol lives on
/// the class, not the interface see `planInterface`).
public let methods: [CallablePlan]
public let doc: String?
public init(name: String, cType: String, prereqs: [String] = [],
getTypeFunction: String? = nil, doc: String? = nil) {
getTypeFunction: String? = nil, methods: [CallablePlan] = [],
doc: String? = nil) {
self.name = name; self.cType = cType; self.prereqs = prereqs
self.getTypeFunction = getTypeFunction; self.doc = doc
self.getTypeFunction = getTypeFunction; self.methods = methods; self.doc = doc
}
}

View file

@ -224,13 +224,38 @@ private func renderRecord(_ plan: RecordPlan) -> String {
if let doc = plan.doc {
lines.append(contentsOf: renderDocComment(doc))
}
lines.append("public final class \(plan.name) {")
lines.append(" public let pointer: UnsafeMutableRawPointer")
lines.append("")
lines.append(" /// Adopts an owned boxed pointer; the wrapper takes responsibility")
lines.append(" /// for freeing it. Use for `transfer-ownership=\"full\"` returns.")
lines.append(" public init(takingOwnership pointer: UnsafeMutableRawPointer) {")
lines.append(" self.pointer = pointer")
lines.append(" }")
// init(retaining:) copies a borrowed pointer so the wrapper owns its own
// instance. Only emitted when a copy/ref function is known.
if let copy = plan.copyFunction {
lines.append("")
lines.append(" /// Copies a borrowed boxed pointer so the wrapper owns an independent")
lines.append(" /// instance. Use for `transfer-ownership=\"none\"` returns.")
lines.append(" public init(retaining pointer: UnsafeMutableRawPointer) {")
lines.append(" self.pointer = _rawPointer(\(copy)(_instancePointer(pointer)))")
lines.append(" }")
}
// deinit frees the owned pointer. `isolated` runs the deinit on the
// type's default actor (MainActor for this package), which is required to
// touch the non-Sendable `pointer` from a deinitialiser under strict
// concurrency. Only emitted when a free/unref function is known; without
// one the wrapper leaks rather than risk freeing with the wrong function.
if let free = plan.freeFunction {
lines.append("")
lines.append(" isolated deinit {")
lines.append(" \(free)(_instancePointer(pointer))")
lines.append(" }")
}
lines.append("}")
return lines.joined(separator: "\n") + "\n"
}
@ -251,10 +276,25 @@ private func renderInterface(_ plan: InterfacePlan) -> String {
lines.append("public protocol \(plan.name)\(protocolInherits) {")
lines.append(" var pointer: UnsafeMutableRawPointer { get }")
for method in plan.methods {
lines.append(contentsOf: renderProtocolRequirement(method))
}
lines.append("}")
return lines.joined(separator: "\n") + "\n"
}
/// Renders one interface method as a protocol requirement: the `func`
/// signature only no `public`, no body. The implementing class provides the
/// body via its own generated method (the C symbol lives on the class).
private func renderProtocolRequirement(_ plan: CallablePlan) -> [String] {
var lines: [String] = []
if let doc = plan.doc { lines.append(contentsOf: renderDocComment(doc).map { " \($0)" }) }
let retType = callableReturnType(plan) ?? ""
let throwsKeyword = plan.throwsError ? " throws" : ""
lines.append(" func \(plan.name)(\(swiftSignature(plan)))\(throwsKeyword)\(retType)")
return lines
}
// Class
private func renderClass(_ plan: ClassPlan) -> String {

View file

@ -194,7 +194,9 @@ private func skipInterface(into skips: inout [SkipEntry], into types: inout [Typ
reason: .notIntrospectable, detail: "non-introspectable interface"))
return 0
}
types.append(.interface(planInterface(iface, context: context)))
let (plan, memberSkips) = planInterface(iface, context: context)
skips.append(contentsOf: memberSkips)
types.append(.interface(plan))
return 1
}
@ -287,35 +289,102 @@ let reservedSwiftTypes: Set<String> = [
"Object", "Type", "Protocol",
]
/// Plans a boxed record as an opaque pointer wrapper.
/// Plans a boxed record as an opaque pointer wrapper, resolving the copy and
/// free functions that give it correct memory management (C4).
func planRecord(_ record: Record, context: MapContext) -> RecordPlan {
return RecordPlan(
name: record.name, cType: record.cType,
getTypeFunction: record.getTypeFunction,
copyFunction: record.copyFunction,
freeFunction: record.freeFunction,
copyFunction: resolvedCopyFunction(record),
freeFunction: resolvedFreeFunction(record),
doc: record.doc
)
}
/// Plans an interface as a Swift protocol stub.
func planInterface(_ iface: Interface, context: MapContext) -> InterfacePlan {
/// Resolves a boxed record's destructor C identifier.
///
/// Prefers the explicit GIR `free-function` attribute; otherwise falls back to
/// the record's own parameterless `unref` (preferred, ref-counted) or `free`
/// method. `g_boxed_free` is deliberately *not* used as a fallback: it lives in
/// GObject, which the GLib module cannot link against (GObject depends on GLib,
/// not the reverse). Returns `nil` when no safe destructor exists the wrapper
/// then renders without a `deinit` (documented leak) rather than risk a wrong
/// free.
func resolvedFreeFunction(_ record: Record) -> String? {
if let explicit = record.freeFunction { return explicit }
return record.instanceReleaseMethod(named: ["unref", "free"])
}
/// Resolves a boxed record's copy/ref C identifier.
///
/// Prefers the explicit GIR `copy-function` attribute; otherwise falls back to
/// the record's own parameterless `ref` (preferred, ref-counted) or `copy`
/// method. Returns `nil` when none exists the wrapper then renders without
/// `init(retaining:)`, and borrowed (`transfer-ownership="none"`) returns of
/// this type must not be adopted as owned.
func resolvedCopyFunction(_ record: Record) -> String? {
if let explicit = record.copyFunction { return explicit }
return record.instanceReleaseMethod(named: ["ref", "copy"])
}
extension Record {
/// The C identifier of the first bindable instance method matching one of
/// `names` (in priority order) that takes no arguments beyond the instance
/// itself the shape every canonical `ref`/`unref`/`copy`/`free` shares.
func instanceReleaseMethod(named names: [String]) -> String? {
for wanted in names {
if let method = methods.first(where: {
$0.name == wanted && $0.symbolInfo.isBindable
&& $0.parameters.allSatisfy(\.isInstanceParameter)
}) {
return method.cIdentifier
}
}
return nil
}
}
/// Plans an interface as a Swift protocol, planning its instance methods as
/// protocol requirements.
///
/// A GObject interface method is dispatched through the implementing class's
/// instance pointer the concrete C symbol (e.g. `gtk_widget_get_buildable_id`)
/// lives on the class, not the interface. So each interface method becomes a
/// protocol *requirement* only; the implementing class supplies the body via
/// its own `planClass` methods. Methods that cannot be planned (unsupported
/// types, varargs, ) are returned as skip entries, mirroring `planClass`.
///
/// - Returns: The interface plan and the skip entries for its unplannable
/// methods.
func planInterface(_ iface: Interface, context: MapContext) -> (plan: InterfacePlan, memberSkips: [SkipEntry]) {
let registry = context.registry
let girName = "\(context.currentNamespace).\(iface.name)"
// Resolve prerequisite types through registry
let prereqSwiftNames: [String] = iface.prereqs.compactMap { prereq in
let qualified = prereq.contains(".") ? prereq : "\(context.currentNamespace).\(prereq)"
guard let resolved = registry.resolve(girName: qualified) else { return nil }
return registry.swiftTypeName(for: resolved, in: context.currentModule)
}
return InterfacePlan(
// Instance methods become protocol requirements. Unplannable ones are
// recorded as skips, matching class member behaviour.
var memberSkips: [SkipEntry] = []
var methodPlans: [CallablePlan] = []
for method in iface.methods where method.symbolInfo.isBindable {
switch planMethod(method, context: context) {
case .success(let plan): methodPlans.append(plan)
case .skip(let entry): memberSkips.append(entry)
}
}
let plan = InterfacePlan(
name: iface.name, cType: iface.cType,
prereqs: prereqSwiftNames,
getTypeFunction: iface.getTypeFunction,
methods: methodPlans,
doc: iface.doc
)
return (plan, memberSkips)
}
/// Plans a class as a GObject wrapper with cross-module inheritance, planning

View file

@ -227,7 +227,11 @@ public struct TypeRegistry: Sendable {
if let forType = rec.isGTypeStructFor {
category = .gtypeStruct(forType: qualify(forType))
} else if rec.isBoxed {
category = .boxedRecord(copyFunction: rec.copyFunction, freeFunction: rec.freeFunction)
// Resolve copy/free the same way the planner does (explicit GIR
// attribute, else the record's own ref/copy/unref/free method)
// so borrowed (transfer="none") returns copy correctly instead
// of adopting a pointer they don't own.
category = .boxedRecord(copyFunction: resolvedCopyFunction(rec), freeFunction: resolvedFreeFunction(rec))
} else {
category = .plainRecord
}

View file

@ -0,0 +1,81 @@
// InterfaceGenerationTests.swift
// Covers Phase C5: GObject interface method planning and rendering. An
// interface method becomes a protocol *requirement* a `func` signature with
// no `public` modifier and no body because the C symbol that implements it
// lives on the conforming class, not the interface. Unplannable methods
// (unsupported parameter types) are recorded as skips, mirroring class
// members. These tests lock in that surface so a regression surfaces here.
import Testing
@testable import SwiftGtkGenCore
@Suite("Interface generation")
struct InterfaceGenerationTests {
/// A registry-backed context in the GLib module. The interface under test
/// needs no named types its methods use primitives and strings only.
func makeContext() -> MapContext {
let glib = Repository(namespaces: [Namespace(name: "GLib", version: "2.0")])
let registry = TypeRegistry(repositories: ["GLib": glib])
return MapContext(registry: registry, currentModule: "GLib", currentNamespace: "GLib")
}
/// Renders a single planned interface to Swift source via the public module
/// renderer, returning the interface's file body.
func render(_ plan: InterfacePlan) -> String {
let module = ModulePlan(
module: "GLib",
types: [.interface(plan)],
skips: [],
coverage: CoverageStats()
)
return renderModule(module)["\(plan.name).swift"] ?? ""
}
@Test("Interface instance methods become protocol requirements")
func methodsBecomeRequirements() throws {
let iface = Interface(
name: "Ping", cType: "GPing",
methods: [
Method(
name: "get_id", cIdentifier: "g_ping_get_id",
parameters: [Parameter(name: "self", type: .pointer, cType: "GPing*",
isInstanceParameter: true)],
returnValue: ReturnValue(type: .long)
)
]
)
let (plan, skips) = planInterface(iface, context: makeContext())
#expect(skips.isEmpty)
#expect(plan.methods.count == 1)
#expect(plan.methods[0].name == "getId")
let source = render(plan)
// A requirement no `public`, no body.
#expect(source.contains("func getId() -> Int"))
#expect(!source.contains("public func getId"))
#expect(!source.contains("g_ping_get_id")) // no body means no C call
}
@Test("Unplannable interface methods are recorded as skips, not requirements")
func unplannableMethodsSkip() throws {
let iface = Interface(
name: "Ping", cType: "GPing",
methods: [
// Variadic methods cannot be bridged must skip, not appear.
Method(
name: "log", cIdentifier: "g_ping_log",
parameters: [
Parameter(name: "self", type: .pointer, cType: "GPing*",
isInstanceParameter: true),
Parameter(name: "...", type: .vaList, cType: ""),
]
)
]
)
let (plan, skips) = planInterface(iface, context: makeContext())
#expect(plan.methods.isEmpty)
#expect(skips.count == 1)
#expect(!render(plan).contains("func log"))
}
}

View file

@ -0,0 +1,95 @@
// RecordGenerationTests.swift
// Covers Phase C4: boxed-record memory management. A boxed wrapper owns its
// pointer, so it needs a `deinit` that frees it and an `init(retaining:)` that
// copies a borrowed pointer. The free/copy functions are resolved from the GIR
// `free-function`/`copy-function` attributes when present, else from the
// record's own `unref`/`free` and `ref`/`copy` methods. These tests lock in
// that resolution and the rendered surface (`isolated deinit`, the copy init).
import Testing
@testable import SwiftGtkGenCore
@Suite("Record generation")
struct RecordGenerationTests {
func makeContext() -> MapContext {
let glib = Repository(namespaces: [Namespace(name: "GLib", version: "2.0")])
let registry = TypeRegistry(repositories: ["GLib": glib])
return MapContext(registry: registry, currentModule: "GLib", currentNamespace: "GLib")
}
/// Renders a single planned record to Swift source via the module renderer.
func render(_ plan: RecordPlan) -> String {
let module = ModulePlan(module: "GLib", types: [.record(plan)], skips: [], coverage: CoverageStats())
return renderModule(module)["\(plan.name).swift"] ?? ""
}
/// A record method taking only its instance parameter (a canonical
/// `ref`/`unref`/`copy`/`free` shape).
func releaseMethod(_ name: String, _ cIdentifier: String) -> Method {
Method(name: name, cIdentifier: cIdentifier,
parameters: [Parameter(name: "self", type: .pointer, isInstanceParameter: true)])
}
@Test("Explicit free/copy attributes take priority over method scan")
func explicitAttributesWin() {
let record = Record(
name: "Bytes", cType: "GBytes", getTypeFunction: "g_bytes_get_type",
copyFunction: "g_bytes_ref", freeFunction: "g_bytes_unref",
methods: [releaseMethod("ref", "g_bytes_ref_method"),
releaseMethod("unref", "g_bytes_unref_method")]
)
#expect(resolvedFreeFunction(record) == "g_bytes_unref")
#expect(resolvedCopyFunction(record) == "g_bytes_ref")
}
@Test("Falls back to the record's own unref/ref methods")
func methodScanFallback() {
// No explicit attributes resolve from methods. `unref`/`ref` are
// preferred over `free`/`copy` when both exist.
let record = Record(
name: "TimeZone", cType: "GTimeZone", getTypeFunction: "g_time_zone_get_type",
methods: [releaseMethod("copy", "g_time_zone_copy"),
releaseMethod("free", "g_time_zone_free"),
releaseMethod("ref", "g_time_zone_ref"),
releaseMethod("unref", "g_time_zone_unref")]
)
#expect(resolvedFreeFunction(record) == "g_time_zone_unref")
#expect(resolvedCopyFunction(record) == "g_time_zone_ref")
}
@Test("A method taking extra arguments is not treated as a destructor")
func destructorMustBeParameterless() {
let record = Record(
name: "Weird", cType: "GWeird", getTypeFunction: "g_weird_get_type",
methods: [Method(name: "free", cIdentifier: "g_weird_free",
parameters: [Parameter(name: "self", type: .pointer, isInstanceParameter: true),
Parameter(name: "flags", type: .int32)])]
)
#expect(resolvedFreeFunction(record) == nil)
}
@Test("Boxed record renders init(retaining:) and an isolated deinit")
func rendersMemoryManagement() {
let plan = RecordPlan(
name: "VariantType", cType: "GVariantType",
getTypeFunction: "g_variant_type_get_gtype",
copyFunction: "g_variant_type_copy", freeFunction: "g_variant_type_free"
)
let source = render(plan)
#expect(source.contains("public init(retaining pointer: UnsafeMutableRawPointer)"))
#expect(source.contains("g_variant_type_copy(_instancePointer(pointer))"))
#expect(source.contains("isolated deinit {"))
#expect(source.contains("g_variant_type_free(_instancePointer(pointer))"))
}
@Test("A record with no resolvable free renders no deinit")
func noFreeNoDeinit() {
let plan = RecordPlan(name: "PollFD", cType: "GPollFD")
let source = render(plan)
#expect(!source.contains("deinit"))
#expect(!source.contains("retaining"))
// The takingOwnership init is always present.
#expect(source.contains("takingOwnership"))
}
}

View file

@ -420,6 +420,18 @@
"reason" : "unknownType",
"symbol" : "GObject.clear_signal_handler"
},
{
"cIdentifier" : "g_type_plugin_complete_interface_info",
"detail" : "parameter 'info': 'GObject.InterfaceInfo' has no GType registration or lifetime functions",
"reason" : "plainRecord",
"symbol" : "GObject.complete_interface_info"
},
{
"cIdentifier" : "g_type_plugin_complete_type_info",
"detail" : "parameter 'info': 'GObject.TypeInfo' has no GType registration or lifetime functions",
"reason" : "plainRecord",
"symbol" : "GObject.complete_type_info"
},
{
"cIdentifier" : "g_signal_group_connect_data",
"detail" : "parameter 'c_handler': callback 'GObject.Callback' not yet supported as a mapped type",