From 257b2e0195a9a416faebb95e623f86dfb6c84262 Mon Sep 17 00:00:00 2001 From: John Fairhurst Date: Mon, 10 Feb 2025 10:04:10 +0000 Subject: [PATCH 1/3] Rethink shutdown, add sample --- Package.swift | 10 +- RubyGateway.xcodeproj/project.pbxproj | 13 ++ Sources/RubyGateway/RbGateway.swift | 11 +- Sources/RubyGateway/RbVM.swift | 7 - Sources/RubyThreadSample/RubyExecutor.swift | 138 ++++++++++++++++++++ Sources/RubyThreadSample/main.swift | 40 ++++++ 6 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 Sources/RubyThreadSample/RubyExecutor.swift create mode 100644 Sources/RubyThreadSample/main.swift diff --git a/Package.swift b/Package.swift index 078a21d..842c5de 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,10 @@ let package = Package( products: [ .library( name: "RubyGateway", - targets: ["RubyGateway", "RubyGatewayHelpers"]) + targets: ["RubyGateway", "RubyGatewayHelpers"]), + .executable( + name: "RubyThreadSample", + targets: ["RubyThreadSample"]) ], dependencies: [ .package(url: "https://github.com/johnfairh/CRuby", from: "2.1.0"), @@ -32,6 +35,9 @@ let package = Package( .testTarget( name: "RubyGatewayTests", dependencies: ["RubyGateway"], - exclude: ["Fixtures"]) + exclude: ["Fixtures"]), + .executableTarget( + name: "RubyThreadSample", + dependencies: ["RubyGateway"]) ] ) diff --git a/RubyGateway.xcodeproj/project.pbxproj b/RubyGateway.xcodeproj/project.pbxproj index bbe3fe5..8bf5b3e 100644 --- a/RubyGateway.xcodeproj/project.pbxproj +++ b/RubyGateway.xcodeproj/project.pbxproj @@ -146,6 +146,8 @@ 02A1D66C2BD1173700B07523 /* RubyGateway.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = RubyGateway.xcconfig; sourceTree = ""; }; 02ABDBA3216CF7BF00AFDB64 /* RbMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RbMethod.swift; sourceTree = ""; }; 02ABDBA5216D060300AFDB64 /* TestMethods.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMethods.swift; sourceTree = ""; }; + 02C143F52D5A0415009C99CF /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 02C143F62D5A0415009C99CF /* RubyExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RubyExecutor.swift; sourceTree = ""; }; 02C5C85020ECD87E007138A2 /* RbComplex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RbComplex.swift; sourceTree = ""; }; 02C5C85220ECE51A007138A2 /* TestComplex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestComplex.swift; sourceTree = ""; }; 02C5C85420F0CD24007138A2 /* RbRational.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RbRational.swift; sourceTree = ""; }; @@ -225,6 +227,16 @@ path = Tests/RubyGatewayTests/Fixtures; sourceTree = SOURCE_ROOT; }; + 02C143F42D5A03C4009C99CF /* RubyThreadSample */ = { + isa = PBXGroup; + children = ( + 02C143F52D5A0415009C99CF /* main.swift */, + 02C143F62D5A0415009C99CF /* RubyExecutor.swift */, + ); + name = RubyThreadSample; + path = Sources/RubyThreadSample; + sourceTree = SOURCE_ROOT; + }; OBJ_12 /* Tests */ = { isa = PBXGroup; children = ( @@ -304,6 +316,7 @@ children = ( OBJ_8 /* RubyGateway */, 022F3ABC2036D58C009E69BE /* RubyGatewayHelpers */, + 02C143F42D5A03C4009C99CF /* RubyThreadSample */, ); name = Sources; sourceTree = SOURCE_ROOT; diff --git a/Sources/RubyGateway/RbGateway.swift b/Sources/RubyGateway/RbGateway.swift index 18b3174..bfd2c5b 100644 --- a/Sources/RubyGateway/RbGateway.swift +++ b/Sources/RubyGateway/RbGateway.swift @@ -15,9 +15,8 @@ internal import RubyGatewayHelpers /// instance `Ruby`. Among other things this permits a dynamic member lookup /// programming style. /// -/// The Ruby VM is initialized when the object is first accessed and is -/// automatically stopped when the process ends. The VM can be manually shut -/// down before process exit by calling `RbGateway.cleanup()` but once this has +/// The Ruby VM is initialized when the object is first accessed. The VM can be manually +/// shut down before process exit by calling `RbGateway.cleanup()` but once this has /// been done the VM cannot be restarted and subsequent calls to RubyGateway /// services will fail. /// @@ -71,16 +70,14 @@ public final class RbGateway: RbObjectAccess, @unchecked Sendable { func setup() throws { if try RbGateway.vm.setup() { try! require(filename: "set") - // Work around Swift not calling static deinit... - atexit { RbGateway.vm.cleanup() } } } /// Explicitly shut down Ruby and release resources. /// This includes calling `END{}` code and procs registered by `Kernel.#at_exit`. /// - /// You generally don't need to call this: it happens automatically as part of - /// process exit. + /// You generally don't need to call this because most code does not rely on a clean process + /// exit. /// /// Once called you cannot continue to use Ruby in this process: the VM cannot /// be re-setup. diff --git a/Sources/RubyGateway/RbVM.swift b/Sources/RubyGateway/RbVM.swift index 14010fb..4dc95fc 100644 --- a/Sources/RubyGateway/RbVM.swift +++ b/Sources/RubyGateway/RbVM.swift @@ -82,13 +82,6 @@ final class RbVM : @unchecked Sendable { } } - /// Shut down Ruby at process exit if possible - /// (Swift seems to not call this for static-scope objects so we don't get here - /// ... there's a compensating atexit() in `RbGateway.setup()`.) - deinit { - cleanup() - } - /// Has Ruby ever been set up in this process? private var setupEver: Bool { rb_mKernel != 0 diff --git a/Sources/RubyThreadSample/RubyExecutor.swift b/Sources/RubyThreadSample/RubyExecutor.swift new file mode 100644 index 0000000..ca337bd --- /dev/null +++ b/Sources/RubyThreadSample/RubyExecutor.swift @@ -0,0 +1,138 @@ +// +// RubyExecutor.swift +// RubyThreadSample +// +// Distributed under the MIT license, see LICENSE +// + +import Foundation +import RubyGateway + +/// A serial executor bound to its own thread. +/// +/// Initializes Ruby on the thread and shuts it down (``RubyGateway.cleanup``) if the executor is stopped. +@available(macOS 14, *) +final class RubyExecutor: SerialExecutor, @unchecked Sendable { + /// Combination mutex & CV protecting ``jobs`` and ``quit`` and ``thread`` + private let cond: NSCondition + + /// Swift concurrency work pending execution + private var jobs: [UnownedJob] + + /// Interlocked state for ``stop()`. + private enum Quit { + case no + case sent + case done + } + private var quit: Quit + + // MARK: Lifecycle + + /// Create a new dedicated-thread executor + /// + /// - Parameters: + /// - qos: The ``QualityOfService`` for the executor's thread. + /// - name: A name for the executor's thread for debug. + public init(qos: QualityOfService = .default, name: String = "RubyExecutor") { + self.cond = NSCondition() + self.jobs = [] + self.quit = .no + self.qos = qos + self.name = name + self.cond.name = "\(name) CV" + self._thread = nil + + Thread.detachNewThread { [unowned self] in + Thread.current.qualityOfService = qos + Thread.current.name = name + thread = Thread.current + threadMain() + thread = nil + } + } + + /// Stop the executor. + /// + /// Blocks until the thread has finished any pending jobs and cleaned up Ruby. + /// If any actors still exist associated with this then they will stop working in a bad way. + /// + /// It's not at all mandatory to call this - only if you are relying on Ruby's "graceful shutdown" + /// path for some reason. + public func stop() { + cond.withLock { + guard quit == .no else { + return + } + quit = .sent + cond.signal() + + while quit == .sent { + cond.wait() + } + } + } + + // MARK: Properties + + /// The `QualityOfService`used by the executor's thread + public let qos: QualityOfService + + /// The (debug) name associated with the executor's thread and locks + public let name: String + + private var _thread: Thread? + + /// The ``Thread`` for the executor, or ``nil`` if it's not running + public private(set) var thread: Thread? { + get { + cond.withLock { _thread } + } + set { + cond.withLock { _thread = newValue } + } + } + + private func threadMain() { + _ = Ruby.softSetup() + + cond.lock() + + while quit == .no { + if jobs.isEmpty { + cond.wait() + } + + let loopJobs = jobs + jobs = [] + + cond.unlock() + + for job in loopJobs { + job.runSynchronously(on: asUnownedSerialExecutor()) + } + + cond.lock() + } + + cond.unlock() + _ = Ruby.cleanup() + cond.lock() + + quit = .done + cond.signal() + + cond.unlock() + } + + /// Send a job to be executed later on the thread + /// + /// Called by the Swift runtime, do not call. :nodoc: + public func enqueue(_ job: consuming ExecutorJob) { + let unownedJob = UnownedJob(job) + cond.withLock { + jobs.append(unownedJob) + cond.signal() + } + } +} diff --git a/Sources/RubyThreadSample/main.swift b/Sources/RubyThreadSample/main.swift new file mode 100644 index 0000000..7163ca5 --- /dev/null +++ b/Sources/RubyThreadSample/main.swift @@ -0,0 +1,40 @@ +// +// main.swift +// RubyThreadSample +// +// Distributed under the MIT license, see LICENSE +// +import RubyGateway + +@available(macOS 14, *) +actor RubyActor { + nonisolated let unownedExecutor: UnownedSerialExecutor + init(executor: RubyExecutor) { + self.unownedExecutor = executor.asUnownedSerialExecutor() + } + + func rand() async throws -> String { + let ver = Ruby.version + let result = try Ruby.eval(ruby: "Kernel.rand") + return "Ruby (\(ver)) random: \(result)" + } +} + +@MainActor +@available(macOS 14, *) +func doRubyWork() async { + do { + let executor = RubyExecutor() + let actor = RubyActor(executor: executor) + let result = try await actor.rand() + print(result) + // This is optional - fine to just let the process exit. + executor.stop() + } catch { + print("error: \(error)") + } +} + +if #available(macOS 14, *) { + await doRubyWork() +} From c7717440a84cc2ff7b9b2e001b12e0bb7f57fa44 Mon Sep 17 00:00:00 2001 From: John Fairhurst Date: Mon, 10 Feb 2025 10:11:18 +0000 Subject: [PATCH 2/3] Xcode project maintenance --- RubyGateway.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/xcschemes/RubyGateway-Package.xcscheme | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/RubyGateway.xcodeproj/project.pbxproj b/RubyGateway.xcodeproj/project.pbxproj index 8bf5b3e..6a0e10b 100644 --- a/RubyGateway.xcodeproj/project.pbxproj +++ b/RubyGateway.xcodeproj/project.pbxproj @@ -432,7 +432,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1600; + LastUpgradeCheck = 1620; TargetAttributes = { 022F3AB62036D4DD009E69BE = { CreatedOnToolsVersion = 9.2; diff --git a/RubyGateway.xcodeproj/xcshareddata/xcschemes/RubyGateway-Package.xcscheme b/RubyGateway.xcodeproj/xcshareddata/xcschemes/RubyGateway-Package.xcscheme index 482c8ef..87a0458 100644 --- a/RubyGateway.xcodeproj/xcshareddata/xcschemes/RubyGateway-Package.xcscheme +++ b/RubyGateway.xcodeproj/xcshareddata/xcschemes/RubyGateway-Package.xcscheme @@ -1,6 +1,6 @@ Date: Mon, 10 Feb 2025 10:25:18 +0000 Subject: [PATCH 3/3] Add sample to CI, update docs --- .github/workflows/test.yml | 4 ++++ README.md | 4 ++-- SourceDocs/User Guide.md | 25 ++++++++++++++++--------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ace2f0..1575345 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -52,6 +52,10 @@ jobs: files: ./coverage.lcov token: ${{ secrets.CODECOV_TOKEN }} verbose: true + - name: Samples + run: | + export PKG_CONFIG_PATH=$(pwd)/Packages/CRuby:$PKG_CONFIG_PATH + swift run RubyThreadSample - name: Tests (Xcodebuild) run: | CRuby/cfg-cruby --mode custom --path `ruby -e 'puts RbConfig::TOPDIR'` diff --git a/README.md b/README.md index 8f1351d..fb181b3 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ print(html) // Create an object. Use keyword arguments with initializer let student = RbObject(ofClass: "Academy::Student", kwArgs: ["name": "barney"])! -// Acess an attribute +// Access an attribute print("Name is \(student.get("name"))") // Fix their name by poking an ivar @@ -158,7 +158,7 @@ log(object2_to_log, priority: 2) ## Requirements * Swift 6.0 or later, from swift.org or Xcode 16+ -* macOS (tested on 14.1) or Linux (tested on Ubuntu Jammy) +* macOS (tested on 15.3) or Linux (tested on Ubuntu Jammy) * Ruby 2.6 or later including development files: * For macOS, these come with Xcode. * For Linux you may need to install a -dev package depending on how your Ruby diff --git a/SourceDocs/User Guide.md b/SourceDocs/User Guide.md index e8db26d..449a4ba 100644 --- a/SourceDocs/User Guide.md +++ b/SourceDocs/User Guide.md @@ -530,15 +530,22 @@ Outside of the very first time, it's not possible to call Ruby on a random thread created either directly by your program or by the Swift concurrency / Dispatch runtime. -A reasonable pattern is to call some Ruby method during system startup on -the Swift `@MainActor` and then treat Ruby calls as requiring isolation to -that actor. If you take calls _from_ Ruby on Ruby-created threads, and -servicing these requires access to your Swift concurrency executors, then you -have to start a `Task` to do this, blocking & then resuming the (Ruby) thread -while that work happens. You have to be really careful with the GVL here to -avoid deadlocks or worse. - -`RbThread` provides some static helpers for creating Ruby threads and +The simplest pattern is to call some Ruby method during system startup on +the Swift `MainActor` and then treat Ruby calls as requiring isolation to +that actor. + +Depending on the Ruby you're using, this may end up blocking your UI and so on. +To avoid this, create a dedicated thread for Ruby and be sure to call Ruby only +on that thread. The easiest way to do this with Swift concurrency is to associate +an executor with a thread and then your Ruby-calling actors with that executor. +There's a sample of this pattern in the `Sources/RubyThreadSample` target. + +If you take calls _from_ Ruby on Ruby-created threads, and servicing these requires +access to your Swift concurrency executors, then you have to start a `Task` to do +this, blocking & then resuming the (Ruby) thread while that work happens. You have +to be really careful with the GVL here to avoid deadlocks or worse. + +`RbThread` provides some static helpers for creating further Ruby threads and relinquishing the GVL: consult the internet for further guidance. ## Caveats and Gotchas