Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rethink shutdown, add sample #59

Merged
merged 3 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'`
Expand Down
10 changes: 8 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -32,6 +35,9 @@ let package = Package(
.testTarget(
name: "RubyGatewayTests",
dependencies: ["RubyGateway"],
exclude: ["Fixtures"])
exclude: ["Fixtures"]),
.executableTarget(
name: "RubyThreadSample",
dependencies: ["RubyGateway"])
]
)
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion RubyGateway.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@
02A1D66C2BD1173700B07523 /* RubyGateway.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = RubyGateway.xcconfig; sourceTree = "<group>"; };
02ABDBA3216CF7BF00AFDB64 /* RbMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RbMethod.swift; sourceTree = "<group>"; };
02ABDBA5216D060300AFDB64 /* TestMethods.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMethods.swift; sourceTree = "<group>"; };
02C143F52D5A0415009C99CF /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
02C143F62D5A0415009C99CF /* RubyExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RubyExecutor.swift; sourceTree = "<group>"; };
02C5C85020ECD87E007138A2 /* RbComplex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RbComplex.swift; sourceTree = "<group>"; };
02C5C85220ECE51A007138A2 /* TestComplex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestComplex.swift; sourceTree = "<group>"; };
02C5C85420F0CD24007138A2 /* RbRational.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RbRational.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -304,6 +316,7 @@
children = (
OBJ_8 /* RubyGateway */,
022F3ABC2036D58C009E69BE /* RubyGatewayHelpers */,
02C143F42D5A03C4009C99CF /* RubyThreadSample */,
);
name = Sources;
sourceTree = SOURCE_ROOT;
Expand Down Expand Up @@ -419,7 +432,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1600;
LastUpgradeCheck = 1620;
TargetAttributes = {
022F3AB62036D4DD009E69BE = {
CreatedOnToolsVersion = 9.2;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
LastUpgradeVersion = "1620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
25 changes: 16 additions & 9 deletions SourceDocs/User Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 4 additions & 7 deletions Sources/RubyGateway/RbGateway.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 0 additions & 7 deletions Sources/RubyGateway/RbVM.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions Sources/RubyThreadSample/RubyExecutor.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
40 changes: 40 additions & 0 deletions Sources/RubyThreadSample/main.swift
Original file line number Diff line number Diff line change
@@ -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()
}