Destroy-notify ABI fixed to GClosureNotify's real 2-arg signature and wired into every connect method (was leaking every _ClosureBox, and UB on non-x86_64 with the wrong arg count). Trampolines restore MainActor isolation via assumeIsolated, with narrowly-scoped nonisolated(unsafe) shadow copies to satisfy Swift 6's sending checker. Interface-signal rendering implemented and unit-tested. Dead code removed (SignalHandlePlan), destroyTrampoline made non-optional, D7 deferral documented in-code. CoverageStats gained boundCallbacks/boundSignals counters. Added SignalGenerationTests, InterfaceSignalGenerationTests, and CallbackGenerationTests (12 new tests, 187/187 total). Fixed the dead nonDetailedSignal smoke test to actually mutate a property and assert the closure fired. Callback-param planner-side binding (D4.3) stays disabled: enabling it trips a genuine Swift compiler crash on g_qsort_with_data's GCompareDataFunc parameter. The renderer-side box setup/release logic is implemented and unit-tested by constructing plans directly, bypassing the blocked planner path. Verified: swift test (187/187), compile-gate.sh 1 --fresh (PASS), smoke-test.sh --fresh (18/18).
167 lines
7.8 KiB
Swift
167 lines
7.8 KiB
Swift
// CallbackGenerationTests.swift
|
|
// Covers Phase D6/D4.3: namespace-level callback typealias emission and
|
|
// callback-typed parameter marshalling.
|
|
//
|
|
// D4.3 status: the planner (`Planner.planParameters`) still blanket-skips
|
|
// every callback-typed parameter with `.callbackWithoutUserData` — real
|
|
// binding is blocked by a Swift compiler ICE ("failed to produce diagnostic
|
|
// for expression") triggered when a `@convention(c)` closure boxed in the
|
|
// generic `_ClosureBox<T>` is passed to a C function expecting a distinct
|
|
// `@convention(c)` typealias (reproduced on `g_qsort_with_data`). See
|
|
// `Planner.swift`'s D4.3 comment and HANDOFF.md. The renderer-side
|
|
// infrastructure (`marshalCallArg`'s `.callbackBox` branch, `callbackBoxSetup`
|
|
// / `callbackBoxRelease`) is implemented and exercised here by constructing
|
|
// `CallablePlan` values directly — bypassing the planner — so the renderer
|
|
// logic is proven correct independent of the planner's current skip.
|
|
|
|
import Testing
|
|
|
|
@testable import SwiftGtkGenCore
|
|
|
|
@Suite("Callback generation")
|
|
struct CallbackGenerationTests {
|
|
func makeContext() -> MapContext {
|
|
let glib = Repository(namespaces: [
|
|
Namespace(name: "GLib", version: "2.0",
|
|
callbacks: [Callback(name: "CompareDataFunc", cType: "GCompareDataFunc")])
|
|
])
|
|
let registry = TypeRegistry(repositories: ["GLib": glib])
|
|
return MapContext(registry: registry, currentModule: "GLib", currentNamespace: "GLib")
|
|
}
|
|
|
|
// MARK: - Typealias emission (D6)
|
|
|
|
@Test("renderCallbackType emits both the @convention(c) form and the Swift-closure form")
|
|
func callbackTypeEmitsBothTypealiases() throws {
|
|
let plan = CallbackTypePlan(
|
|
name: "CompareFunc",
|
|
swiftType: "(UnsafeRawPointer?, UnsafeRawPointer?) -> Int32",
|
|
cSwiftType: "@convention(c) (UnsafeRawPointer?, UnsafeRawPointer?) -> Int32"
|
|
)
|
|
let module = ModulePlan(module: "GLib", types: [.callback(plan)], skips: [],
|
|
coverage: CoverageStats())
|
|
let files = renderModule(module)
|
|
let source = files["Callbacks.swift"] ?? ""
|
|
#expect(source.contains("public typealias CompareFunc = @convention(c) (UnsafeRawPointer?, UnsafeRawPointer?) -> Int32"))
|
|
#expect(source.contains("public typealias CompareFuncSwift = (UnsafeRawPointer?, UnsafeRawPointer?) -> Int32"))
|
|
}
|
|
|
|
// MARK: - Planner baseline (unchanged pending D4.3)
|
|
|
|
@Test("A callback-typed parameter still skips with callbackWithoutUserData (D4.3 deferred)")
|
|
func callbackParamStillSkipped() throws {
|
|
let fn = GlobalFunction(
|
|
name: "qsort_with_data", cIdentifier: "g_qsort_with_data",
|
|
parameters: [
|
|
Parameter(name: "compare_func", type: .typeRef("CompareDataFunc", namespace: "GLib"),
|
|
cType: "GCompareDataFunc"),
|
|
]
|
|
)
|
|
guard case .skip(let entry) = planFunction(fn, context: makeContext()) else {
|
|
Issue.record("expected callback-typed param to still skip pending D4.3"); return
|
|
}
|
|
#expect(entry.reason == .callbackWithoutUserData)
|
|
}
|
|
|
|
// MARK: - Renderer infrastructure (constructed directly — D4.3 render-side proof)
|
|
|
|
/// A `.callbackBox(scope: .call, …)` parameter that doubles as its own
|
|
/// user-data slot (`closureIndex == cArgIndex`, GLib's common
|
|
/// `..._with_data` pattern) — mirrors the shape `g_qsort_with_data` would
|
|
/// plan to once D4.3's planner-side ICE is resolved.
|
|
var scopeCallCallable: CallablePlan {
|
|
let callbackMapping = Mapping(
|
|
swiftType: "(UnsafeRawPointer?, UnsafeRawPointer?, UnsafeMutableRawPointer?) -> Int32",
|
|
cSwiftType: "@convention(c) (UnsafeRawPointer?, UnsafeRawPointer?, UnsafeMutableRawPointer?) -> Int32",
|
|
marshalIn: .callbackBox(scope: .call, destroyTrampoline: ""),
|
|
marshalOut: .direct,
|
|
category: .callback
|
|
)
|
|
let param = ParameterPlan(
|
|
swiftName: "compareFunc", cArgIndex: 0,
|
|
mapping: callbackMapping, closureIndex: 0
|
|
)
|
|
return CallablePlan(name: "qsortWithData", cIdentifier: "g_qsort_with_data", parameters: [param])
|
|
}
|
|
|
|
@Test("A scope=.call callback param has no separate user-data ParameterPlan when closureIndex == cArgIndex")
|
|
func scopeCallHasNoSeparateUserData() throws {
|
|
let plan = scopeCallCallable
|
|
#expect(plan.parameters.count == 1)
|
|
let param = plan.parameters[0]
|
|
guard case .callbackBox(let scope, let destroyTrampoline) = param.mapping.marshalIn else {
|
|
Issue.record("expected .callbackBox marshalIn"); return
|
|
}
|
|
#expect(scope == .call)
|
|
#expect(destroyTrampoline == "")
|
|
#expect(param.closureIndex == param.cArgIndex)
|
|
}
|
|
|
|
@Test("renderCallable for a scope=.call callback emits box setup before the C call and release after")
|
|
func scopeCallEmitsSetupAndRelease() throws {
|
|
let module = ModulePlan(module: "GLib", types: [.callable(scopeCallCallable)], skips: [],
|
|
coverage: CoverageStats())
|
|
let files = renderModule(module)
|
|
let source = files["Functions.swift"] ?? ""
|
|
|
|
// Setup: box the closure and take an opaque retained pointer BEFORE
|
|
// the C call.
|
|
#expect(source.contains("_ClosureBox(compareFunc)"))
|
|
#expect(source.contains("Unmanaged.passRetained"))
|
|
#expect(source.contains("g_qsort_with_data("))
|
|
|
|
// Release: scope=.call takes the closure back and releases it AFTER
|
|
// the C call returns (matches gtk-rs stack-borrow lifetime) — the box
|
|
// must not leak.
|
|
#expect(source.contains("takeRetainedValue()"))
|
|
|
|
// Ordering: the setup line must appear before the C call line, and
|
|
// the release line after it.
|
|
let setupIdx = source.range(of: "Unmanaged.passRetained")!.lowerBound
|
|
let callIdx = source.range(of: "g_qsort_with_data(")!.lowerBound
|
|
let releaseIdx = source.range(of: "takeRetainedValue()")!.lowerBound
|
|
#expect(setupIdx < callIdx)
|
|
#expect(callIdx < releaseIdx)
|
|
}
|
|
|
|
@Test("Support.swift emits the closure-box runtime when a module has callback-param callables but no signals")
|
|
func supportEmitsClosureBoxForCallbacksWithoutSignals() throws {
|
|
let module = ModulePlan(module: "GLib", types: [.callable(scopeCallCallable)], skips: [],
|
|
coverage: CoverageStats())
|
|
let files = renderModule(module)
|
|
let support = files["Support.swift"] ?? ""
|
|
#expect(support.contains("_ClosureBox"))
|
|
}
|
|
|
|
// MARK: - CoverageStats (R7 / D8)
|
|
|
|
@Test("CoverageStats tallies bound callbacks and signals from a fixture namespace")
|
|
func coverageStatsCountsCallbacksAndSignals() throws {
|
|
let ns = Namespace(
|
|
name: "GLib", version: "2.0",
|
|
classes: [
|
|
Class(name: "Emitter", cType: "GEmitter", parent: nil,
|
|
getTypeFunction: "g_emitter_get_type",
|
|
signals: [Signal(name: "fired", isDetailed: false)]),
|
|
],
|
|
callbacks: [
|
|
Callback(name: "SimpleCallback", cType: "GSimpleCallback"),
|
|
]
|
|
)
|
|
let repo = Repository(namespaces: [ns])
|
|
let analysis = MultiPackageAnalysis(
|
|
repositories: ["GLib": repo],
|
|
directDependencies: ["GLib": []],
|
|
transitiveDependencies: ["GLib": []],
|
|
implicitImports: ["GLib": []],
|
|
packageConfigs: [:]
|
|
)
|
|
let registry = TypeRegistry(repositories: ["GLib": repo])
|
|
let module = planModules(analysis: analysis, registry: registry)["GLib"]
|
|
let coverage = module?.coverage ?? CoverageStats()
|
|
#expect(coverage.boundCallbacks == 1)
|
|
#expect(coverage.totalCallbacks == 1)
|
|
#expect(coverage.boundSignals == 1)
|
|
#expect(coverage.totalSignals == 1)
|
|
}
|
|
}
|