Skip to content

Commit

Permalink
Add Sendable requirements on vulnerable closure types
Browse files Browse the repository at this point in the history
  • Loading branch information
johnfairh committed Apr 17, 2024
1 parent 9c1c3fb commit 1b4602c
Show file tree
Hide file tree
Showing 8 changed files with 68 additions and 46 deletions.
10 changes: 10 additions & 0 deletions SourceDocs/User Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,16 @@ RubyGateway caches intern'ed Ruby strings - you can access the cache using
Note that when you call the Ruby API and Ruby raises an exception, the process
immediately crashes unless you are running inside `rb_protect()` or equivalent.
## Swift Concurrency
Sendable annotations and checking are mostly complete. The parts remaining are
* `RbBlockCallback` - Swift doesn't understand @Sendable typealiases.
* `RbMethodCallback` and related - Swift doesn't understand Sendable method
references.
Despite the lack of `Sendable` requirement on these closure types they should
be treated as such if you are using Ruby across multiple threads.
### Garbage collection
The main risk using the `libruby` API is that GC happens too early on objects
Expand Down
4 changes: 2 additions & 2 deletions Sources/RubyGateway/RbGateway.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ internal import RubyGatewayHelpers
/// and `RbObject.defineSingletonMethod(...)`.
///
public final class RbGateway: RbObjectAccess, @unchecked Sendable {

/// The VM - not initialized until `setup()` is called.
static let vm = RbVM()

Expand Down Expand Up @@ -164,7 +163,8 @@ public final class RbGateway: RbObjectAccess, @unchecked Sendable {
try name.checkRubyInstanceVarName()

let ivarWorkaroundName = "$RbGatewayTopSelfIvarWorkaround"
try defineGlobalVar(ivarWorkaroundName, get: { newValue.rubyObject })
let rubyObject = newValue.rubyObject
try defineGlobalVar(ivarWorkaroundName, get: { rubyObject })
return try eval(ruby: "\(name) = \(ivarWorkaroundName)")
}
}
Expand Down
6 changes: 3 additions & 3 deletions Sources/RubyGateway/RbGlobalVar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ extension RbGateway {
/// - throws: `RbError.badIdentifier(type:id:)` if `name` is bad; some other kind of error if Ruby is
/// not working.
public func defineGlobalVar<T: RbObjectConvertible>(_ name: String,
get: @escaping () -> T) throws {
get: @Sendable @escaping () -> T) throws {
try setup()
try name.checkRubyGlobalVarName()
RbGlobalVar.create(name: name, get: get, set: nil)
Expand All @@ -119,8 +119,8 @@ extension RbGateway {
/// - throws: `RbError.badIdentifier(type:id:)` if `name` is bad; some other kind of error if Ruby is
/// not working.
public func defineGlobalVar<T: RbObjectConvertible>(_ name: String,
get: @escaping () -> T,
set: @escaping (T) throws -> Void) throws {
get: @Sendable @escaping () -> T,
set: @Sendable @escaping (T) throws -> Void) throws {
try setup()
try name.checkRubyGlobalVarName()
RbGlobalVar.create(name: name, get: get, set: set)
Expand Down
35 changes: 24 additions & 11 deletions Sources/RubyGateway/RbMethod.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ internal import RubyGatewayHelpers
// dynamic dispatch order. So we can search this property looking for a match.
// OK - not THAT bad!

// XXX these guys all ought to be Sendable, but Swift is broken wrt Sendable and
// XXX method references....

/// The function signature for a Ruby method implemented as a Swift free function
/// or closure.
///
Expand Down Expand Up @@ -59,7 +62,7 @@ public typealias RbMethodCallback = (RbObject, RbMethod) throws -> RbObject
/// You can throw an `RbException` to raise a Ruby exception instead of returning
/// normally from the method. Throwing another type gets wrapped up in an
/// `RbException` and raised as a Ruby runtime exception.
public typealias RbBoundMethodCallback<SwiftPeer: AnyObject, Return: RbObjectConvertible> =
public typealias RbBoundMethodCallback<SwiftPeer: AnyObject, Return: RbObjectConvertible & Sendable> =
(SwiftPeer) -> (RbMethod) throws -> Return

/// The function signature for a Ruby method implemented as a Swift method of
Expand Down Expand Up @@ -189,7 +192,7 @@ private struct RbMethodDispatch {
///
/// You do not create instances of this type: instead, RubyGateway creates
/// instances and passes them to method callbacks.
public struct RbMethod {
public struct RbMethod: Sendable {
/// The object against which the method has been invoked.
public let rubySelf: RbObject
/// The arguments passed to the method, decoded according to the method's `RbMethodArgsSpec`.
Expand Down Expand Up @@ -288,7 +291,7 @@ extension Array {
/// The various types of argument passed to a Ruby method implemented in Swift.
///
/// Available via `RbMethod.args` when the method is invoked.
public struct RbMethodArgs {
public struct RbMethodArgs: Sendable {
/// The mandatory positional arguments to the method, comprising the
/// leading mandatory arguments followed by the trailing mandatory arguments.
public let mandatory: [RbObject]
Expand Down Expand Up @@ -317,11 +320,11 @@ public struct RbMethodArgs {
///
/// If you want to say "accept any number of arguments" then write
/// `RbMethodArgsSpec(supportsSplat: true)` and access the arguments via `method.args.splatted`.
public struct RbMethodArgsSpec {
public struct RbMethodArgsSpec: Sendable {
/// The number of leading mandatory positional arguments.
public let leadingMandatoryCount: Int
/// Default values for all optional positional arguments.
public let optionalValues: [() -> RbObject]
public let optionalValues: [@Sendable () -> RbObject]
/// The number of optional positional arguments.
public var optionalCount: Int {
optionalValues.count
Expand All @@ -337,7 +340,7 @@ public struct RbMethodArgsSpec {
/// Names of mandatory keyword arguments.
public let mandatoryKeywords: Set<String>
/// Names and default values of optional keyword arguments.
public let optionalKeywordValues: [String : () -> RbObject]
public let optionalKeywordValues: [String : @Sendable () -> RbObject]
/// Does the method support keyword arguments?
public var supportsKeywords: Bool {
mandatoryKeywords.count > 0 || optionalKeywordValues.count > 0
Expand All @@ -360,6 +363,11 @@ public struct RbMethodArgsSpec {

/// Create a new method arguments specification.
///
/// The default values here are evaluated lazily: each time the method is invoked and requires the
/// default argument because caller has not provided it, the `RbObjectConvertible.rubyObject`
/// is evaluated and provided to the method. In the case of constant defaults this is unobservable but
/// does give correct behaviour if you actually supply something that turns into a Ruby expression.
///
/// - Parameters:
/// - leadingMandatoryCount: The number of leading mandatory positional arguments,
/// none by default.
Expand All @@ -373,18 +381,23 @@ public struct RbMethodArgsSpec {
/// - requiresBlock: Whether the method requires a block, `false` by default. If this is
/// `true` then the method may or may not be called with a block.
public init(leadingMandatoryCount: Int = 0,
optionalValues: [(any RbObjectConvertible)?] = [],
optionalValues: [(any RbObjectConvertible & Sendable)?] = [],
supportsSplat: Bool = false,
trailingMandatoryCount: Int = 0,
mandatoryKeywords: Set<String> = [],
optionalKeywordValues: [String: (any RbObjectConvertible)?] = [:],
optionalKeywordValues: [String: (any RbObjectConvertible & Sendable)?] = [:],
requiresBlock: Bool = false) {
self.leadingMandatoryCount = leadingMandatoryCount
self.optionalValues = optionalValues.map { val in { val.rubyObject } }

func lazyEvaluation(_ o: (any RbObjectConvertible & Sendable)?) -> @Sendable () -> RbObject {
{ o.map { $0.rubyObject } ?? .nilObject }
}

self.optionalValues = optionalValues.map { lazyEvaluation($0) }
self.supportsSplat = supportsSplat
self.trailingMandatoryCount = trailingMandatoryCount
self.mandatoryKeywords = mandatoryKeywords
self.optionalKeywordValues = optionalKeywordValues.mapValues { val in { val.rubyObject } }
self.optionalKeywordValues = optionalKeywordValues.mapValues { lazyEvaluation($0) }
self.requiresBlock = requiresBlock
}

Expand Down Expand Up @@ -651,7 +664,7 @@ extension RbObject {
/// - method: The Swift method to call to fulfill the Ruby method.
/// - Throws: `RbError.badIdentifier(type:id:)` if `name` is bad.
/// `RbError.badType(...)` if the object is not a class.
public func defineMethod<SwiftPeer: AnyObject, Return: RbObjectConvertible>(
public func defineMethod<SwiftPeer: AnyObject, Return: RbObjectConvertible & Sendable>(
_ name: String,
argsSpec: RbMethodArgsSpec = RbMethodArgsSpec(),
method: @escaping RbBoundMethodCallback<SwiftPeer, Return>) throws {
Expand Down
6 changes: 3 additions & 3 deletions Tests/RubyGatewayTests/TestClassDef.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class TestClassDef: XCTestCase {

let parentClass = try Ruby.get("MyParentClass")
let myClass = try Ruby.defineClass("MyClass", parent: parentClass, under: innerMod)
var called = false
nonisolated(unsafe) var called = false
try myClass.defineMethod("value") { _, _ in
called = true
return RbObject(100)
Expand Down Expand Up @@ -122,7 +122,7 @@ class TestClassDef: XCTestCase {

// Bound Swift classes

class MyBoundClass {
class MyBoundClass: @unchecked Sendable {

static nonisolated(unsafe) var initCount = 0
static nonisolated(unsafe) var deinitCount = 0
Expand All @@ -139,7 +139,7 @@ class TestClassDef: XCTestCase {
var fingerprint = MyBoundClass.fingerprintValue

func getFingerprint(method: RbMethod) throws -> RbObject {
return RbObject(fingerprint)
RbObject(fingerprint)
}

var generation: Int
Expand Down
4 changes: 2 additions & 2 deletions Tests/RubyGatewayTests/TestGlobalVars.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class TestGlobalVars: XCTestCase {
let initialValue = 22
let newValue = 108

var modelValue = initialValue
nonisolated(unsafe) var modelValue = initialValue

let gvarName = "$myVirtualGlobal"

Expand Down Expand Up @@ -46,7 +46,7 @@ class TestGlobalVars: XCTestCase {
let initialIntValue = 100
let targetStringValue = "Berry"

var wrappedObj = RbObject(initialIntValue)
nonisolated(unsafe) var wrappedObj = RbObject(initialIntValue)
let gvarName = "$myGlobal"

try Ruby.defineGlobalVar(gvarName,
Expand Down
34 changes: 17 additions & 17 deletions Tests/RubyGatewayTests/TestMethods.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class TestMethods: XCTestCase {
let argCount = 1
let argValue = "Fish"
let retValue = 8.9
var visited = false
nonisolated(unsafe) var visited = false

try Ruby.defineGlobalFunction(funcName, argsSpec: RbMethodArgsSpec(leadingMandatoryCount: argCount)) { _, method in
method.checkArgs()
Expand All @@ -65,7 +65,7 @@ class TestMethods: XCTestCase {
doErrorFree {
let funcName = "myGlobal"
let retValue = 8.9
var visited = false
nonisolated(unsafe) var visited = false

try Ruby.defineGlobalFunction(funcName) { _, method in
method.checkArgs()
Expand Down Expand Up @@ -104,7 +104,7 @@ class TestMethods: XCTestCase {
func testGoodBlock() {
doErrorFree {
let funcName = "myGlobal"
var funcCalled = false
nonisolated(unsafe) var funcCalled = false
var blockCalled = false
let expectedBlockResult = 4.0
let expectedFuncResult = "alldone"
Expand Down Expand Up @@ -140,7 +140,7 @@ class TestMethods: XCTestCase {
func testErrorNoBlock() {
doErrorFree {
let funcName = "myGlobal"
var funcCalled = false
nonisolated(unsafe) var funcCalled = false

try Ruby.defineGlobalFunction(funcName) { _, method in
funcCalled = true
Expand All @@ -159,8 +159,8 @@ class TestMethods: XCTestCase {
func testManualBlock() {
doErrorFree {
let funcName = "myGlobal"
var funcCalled = false
var blockCalled = false
nonisolated(unsafe) var funcCalled = false
nonisolated(unsafe) var blockCalled = false

try Ruby.defineGlobalFunction(funcName) { _, method in
let block = try method.captureBlock()
Expand All @@ -184,8 +184,8 @@ class TestMethods: XCTestCase {
func testBlockArgs() {
doErrorFree {
let funcName = "myGlobal"
var funcCalled = false
var blockCalled = false
nonisolated(unsafe) var funcCalled = false
nonisolated(unsafe) var blockCalled = false
let expectedBlockArg = 4.0

try Ruby.defineGlobalFunction(funcName, argsSpec: RbMethodArgsSpec(requiresBlock: true)) { _, method in
Expand Down Expand Up @@ -284,7 +284,7 @@ class TestMethods: XCTestCase {
// def f(a=3, b=4)
let spec_f = RbMethodArgsSpec(optionalValues: [3, 4])
let func_f = "f"
var expectedArgs_f: [RbObject] = []
nonisolated(unsafe) var expectedArgs_f: [RbObject] = []
try Ruby.defineGlobalFunction(func_f, argsSpec: spec_f) { _, method in
method.checkArgs()
XCTAssertEqual(expectedArgs_f, method.args.optional)
Expand All @@ -311,7 +311,7 @@ class TestMethods: XCTestCase {
// def f(*a)
let spec_f = RbMethodArgsSpec(supportsSplat: true)
let func_f = "f"
var expectedArgs_f: [RbObject] = []
nonisolated(unsafe) var expectedArgs_f: [RbObject] = []
try Ruby.defineGlobalFunction(func_f, argsSpec: spec_f) { _, method in
method.checkArgs()
XCTAssertEqual(expectedArgs_f, method.args.splatted)
Expand All @@ -337,9 +337,9 @@ class TestMethods: XCTestCase {
supportsSplat: true,
trailingMandatoryCount: 1)
let func_f = "f"
var a_val: RbObject = .nilObject
var c_val: RbObject = .nilObject
var b_count: Int = 0
nonisolated(unsafe) var a_val: RbObject = .nilObject
nonisolated(unsafe) var c_val: RbObject = .nilObject
nonisolated(unsafe) var b_count: Int = 0
try Ruby.defineGlobalFunction(func_f, argsSpec: spec_f) { _, method in
method.checkArgs()
XCTAssertEqual(a_val, method.args.mandatory[0])
Expand Down Expand Up @@ -398,7 +398,7 @@ class TestMethods: XCTestCase {
// def f(ar:)
let spec_f = RbMethodArgsSpec(mandatoryKeywords: [argKey])
let expectedVal = 211.4896
var called = false
nonisolated(unsafe) var called = false

try Ruby.defineGlobalFunction(func_f, argsSpec: spec_f) { _, method in
method.checkArgs()
Expand Down Expand Up @@ -462,7 +462,7 @@ class TestMethods: XCTestCase {
let f_opt_arg_def = "fish"
let spec_f = RbMethodArgsSpec(optionalKeywordValues: [f_opt_arg_kw: f_opt_arg_def])

var expect_arg_val = ""
nonisolated(unsafe) var expect_arg_val = ""
try Ruby.defineGlobalFunction(func_f, argsSpec: spec_f) { _, method in
method.checkArgs()
XCTAssertEqual(expect_arg_val, String(method.args.keyword[f_opt_arg_kw]!))
Expand Down Expand Up @@ -490,8 +490,8 @@ class TestMethods: XCTestCase {
optionalValues: [b_def],
optionalKeywordValues: [kw_c: kw_c_def])

var expect_a = ""
var expect_b = RbObject(b_def)
nonisolated(unsafe) var expect_a = ""
nonisolated(unsafe) var expect_b = RbObject(b_def)

try Ruby.defineGlobalFunction(func_f, argsSpec: spec_f) { _, method in
method.checkArgs()
Expand Down
15 changes: 7 additions & 8 deletions Tests/RubyGatewayTests/TestObjMethods.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ class TestObjMethods: XCTestCase {
doErrorFree {
try Ruby.require(filename: Helpers.fixturePath("swift_obj_methods.rb"))


var callCount = 0
nonisolated(unsafe) var callCount = 0

let clazz = try Ruby.get("EmptyClass")

Expand Down Expand Up @@ -68,7 +67,7 @@ class TestObjMethods: XCTestCase {
doErrorFree {
try Ruby.require(filename: Helpers.fixturePath("swift_obj_methods.rb"))

var called = false
nonisolated(unsafe) var called = false

let module = try Ruby.get("EmptyModule")
XCTAssertEqual(RbType.T_MODULE, module.rubyType)
Expand All @@ -88,7 +87,7 @@ class TestObjMethods: XCTestCase {
doErrorFree {
try Ruby.require(filename: Helpers.fixturePath("swift_obj_methods.rb"))

var callCount = 0
nonisolated(unsafe) var callCount = 0

let clazz = try Ruby.get("IdentifiedClass")
try clazz.defineMethod("doubleId") { rbSelf, method in
Expand All @@ -107,7 +106,7 @@ class TestObjMethods: XCTestCase {
doErrorFree {
try Ruby.require(filename: Helpers.fixturePath("swift_obj_methods.rb"))

var callCount = 0
nonisolated(unsafe) var callCount = 0

let clazz = try Ruby.get("BaseClass")
try clazz.defineMethod("getValue") { rbSelf, method in
Expand All @@ -125,7 +124,7 @@ class TestObjMethods: XCTestCase {
doErrorFree {
try Ruby.require(filename: Helpers.fixturePath("swift_obj_methods.rb"))

var callCount = 0
nonisolated(unsafe) var callCount = 0

let clazz = try Ruby.get("OverriddenClass")
try clazz.defineMethod("getValue") { rbSelf, method in
Expand Down Expand Up @@ -158,7 +157,7 @@ class TestObjMethods: XCTestCase {
func testSingleton() {
doErrorFree {
let module = try Ruby.get("Math")
var called = false
nonisolated(unsafe) var called = false
try module.defineSingletonMethod("double", argsSpec: .basic(1)) { _, method in
called = true
return method.args.mandatory[0] * 2
Expand Down Expand Up @@ -207,7 +206,7 @@ class TestObjMethods: XCTestCase {
doErrorFree {
try Ruby.require(filename: Helpers.fixturePath("swift_obj_methods.rb"))

var called = false
nonisolated(unsafe) var called = false

let clazz = try Ruby.get("SingBase")
try clazz.defineSingletonMethod("value2") { rbSelf, _ in
Expand Down

0 comments on commit 1b4602c

Please sign in to comment.