portico/Sources/Portico/MainLoop/MainLoopSources.swift

191 lines
7.6 KiB
Swift

@_spi(SGTKInternal) import GLib
// MARK: - Callback box
/// Retained owner of a GLib source callback. GLib holds exactly one strong
/// reference to this box, released by the source's `GDestroyNotify`.
@MainActor final class SourceCallbackBox {
private let body: @MainActor (SourceId) -> Void
private let repeats: Bool
private let owner: SourceId
init(owner: SourceId, repeats: Bool, body: @escaping @MainActor (SourceId) -> Void) {
self.owner = owner
self.repeats = repeats
self.body = body
}
/// Runs the callback. Returns `true` for `G_SOURCE_CONTINUE`, `false` for
/// `G_SOURCE_REMOVE`.
///
/// Pins locals before calling `body` so that a body which removes its own
/// source (triggering synchronous `g_source_destroy` -> `GDestroyNotify` ->
/// box deallocation) does not cause use-after-free when this method reads
/// `repeats` and `owner.isRemoved` afterward.
func invoke() -> Bool {
let owner = self.owner // local strong ref survives box dealloc
let repeats = self.repeats
body(owner)
return repeats && !owner.isRemoved
}
}
// MARK: - Trampolines
/// GLib `GSourceFunc` trampoline. Bridges the C callback back to the main
/// actor via `MainActor.assumeIsolated`. Dispatch runs on the thread pumping
/// the GLib main context, which must be the process's main thread.
@_cdecl("portico_source_dispatch")
nonisolated func portico_source_dispatch(_ data: UnsafeMutableRawPointer?) -> Int32 {
guard let data else { return 0 }
let box = Unmanaged<SourceCallbackBox>.fromOpaque(data).takeUnretainedValue()
return withExtendedLifetime(box) {
MainActor.assumeIsolated { box.invoke() ? 1 : 0 }
}
}
/// GLib `GDestroyNotify` trampoline. Releases the +1 retain that GLib holds
/// on the callback box.
///
/// Destroy-notify may run on any thread, so this is a plain nonisolated
/// refcount release rather than a `MainActor.assumeIsolated` call.
@_cdecl("portico_source_destroy_notify")
nonisolated func portico_source_destroy_notify(_ data: UnsafeMutableRawPointer?) {
guard let data else { return }
Unmanaged<SourceCallbackBox>.fromOpaque(data).release()
}
// MARK: - Duration conversion
/// Converts a `Duration` to whole GLib milliseconds. Negative durations clamp
/// to `0`; a non-zero sub-millisecond remainder rounds up to `1`; values beyond
/// `UInt32.max` milliseconds clamp to `UInt32.max`.
@_spi(Portico) public func _clampedMilliseconds(_ duration: Duration) -> UInt32 {
let c = duration.components
if c.seconds < 0 || c.attoseconds < 0 { return 0 }
let attosecondsPerMillisecond: Int64 = 1_000_000_000_000_000
guard c.seconds <= Int64(UInt32.max) / 1000 else { return UInt32.max }
var total = c.seconds * 1000 + c.attoseconds / attosecondsPerMillisecond
if c.attoseconds % attosecondsPerMillisecond > 0 { total += 1 }
return total > Int64(UInt32.max) ? UInt32.max : UInt32(total)
}
// MARK: - Attach helper
/// Sets the priority and callback on `source`, attaches it to the default main
/// context, and returns its handle.
@MainActor
private func attachSource(
_ source: GLib.Source,
priority: SourcePriority,
repeats: Bool,
body: @escaping @MainActor (SourceId) -> Void
) -> SourceId {
let id = SourceId(source: source)
let box = SourceCallbackBox(owner: id, repeats: repeats, body: body)
let dispatch: @convention(c) (UnsafeMutableRawPointer?) -> Int32 = portico_source_dispatch
let notify: @convention(c) (UnsafeMutableRawPointer?) -> Void = portico_source_destroy_notify
let ptr = source.pointer
portico_g_source_set_priority(ptr, priority.rawValue)
portico_g_source_set_callback(ptr, dispatch, Unmanaged.passRetained(box).toOpaque(), notify)
id.rawValue = portico_g_source_attach(ptr, nil)
return id
}
// MARK: - Idle
/// Runs `body` when the GLib main loop has no higher-priority work pending.
///
/// Attaches a `g_idle_source_new` source to the default main context. The
/// closure receives the source's own ``SourceId`` so it can stop itself.
///
/// - Parameters:
/// - priority: Source priority. Defaults to ``SourcePriority/defaultIdle``.
/// - repeats: When `true`, `body` runs on every idle iteration until removed.
/// Defaults to `false` (run once). A repeating idle source keeps the main
/// loop from sleeping - prefer `Timeout` for periodic work.
/// - body: The work to perform on the main thread.
/// - Returns: A handle that removes the source; discardable.
@MainActor @discardableResult
public func Idle(
priority: SourcePriority = .defaultIdle,
repeats: Bool = false,
_ body: @escaping @MainActor (SourceId) -> Void
) -> SourceId {
attachSource(GLib.idleSourceNew(), priority: priority, repeats: repeats, body: body)
}
/// Runs `body` when the GLib main loop has no higher-priority work pending.
///
/// Convenience overload for closures that do not need the source handle.
///
/// - Parameters:
/// - priority: Source priority. Defaults to ``SourcePriority/defaultIdle``.
/// - repeats: When `true`, `body` runs on every idle iteration until removed.
/// Defaults to `false` (run once).
/// - body: The work to perform on the main thread.
/// - Returns: A handle that removes the source; discardable.
@MainActor @discardableResult
public func Idle(
priority: SourcePriority = .defaultIdle,
repeats: Bool = false,
_ body: @escaping @MainActor () -> Void
) -> SourceId {
Idle(priority: priority, repeats: repeats) { _ in body() }
}
// MARK: - Timeout
/// Runs `body` repeatedly at `interval` on the GLib main loop.
///
/// Attaches a `g_timeout_source_new` source to the default main context. The
/// interval is monotonic and millisecond-granular; GLib may delay a fire when
/// higher-priority sources are busy and does not try to catch up lost time, so
/// do not rely on this for precise timing. The first fire is one `interval`
/// after attach.
///
/// - Parameters:
/// - interval: Time between fires. Rounded up to whole milliseconds; negative
/// values clamp to `0`.
/// - priority: Source priority. Defaults to ``SourcePriority/default``.
/// - repeats: When `false`, the source is removed after the first fire.
/// Defaults to `true`.
/// - body: The work to perform on the main thread; receives the source's own
/// ``SourceId`` so it can stop itself.
/// - Returns: A handle that removes the source; discardable.
@MainActor @discardableResult
public func Timeout(
interval: Duration,
priority: SourcePriority = .default,
repeats: Bool = true,
_ body: @escaping @MainActor (SourceId) -> Void
) -> SourceId {
attachSource(
GLib.timeoutSourceNew(interval: _clampedMilliseconds(interval)),
priority: priority,
repeats: repeats,
body: body
)
}
/// Runs `body` repeatedly at `interval` on the GLib main loop.
///
/// Convenience overload for closures that do not need the source handle.
///
/// - Parameters:
/// - interval: Time between fires. See ``Timeout(interval:priority:repeats:_:)``
/// for granularity and drift behavior.
/// - priority: Source priority. Defaults to ``SourcePriority/default``.
/// - repeats: When `false`, the source is removed after the first fire.
/// Defaults to `true`.
/// - body: The work to perform on the main thread.
/// - Returns: A handle that removes the source; discardable.
@MainActor @discardableResult
public func Timeout(
interval: Duration,
priority: SourcePriority = .default,
repeats: Bool = true,
_ body: @escaping @MainActor () -> Void
) -> SourceId {
Timeout(interval: interval, priority: priority, repeats: repeats) { _ in body() }
}