Skip to content

Integrate uv_loop with CFRunLoop #52

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

Draft
wants to merge 24 commits into
base: main
Choose a base branch
from
Draft
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
14 changes: 14 additions & 0 deletions .swiftpm/xcode/xcshareddata/xcschemes/node-swift-Package.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "NodeUV"
BuildableName = "NodeUV"
BlueprintName = "NodeUV"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
Expand Down
9 changes: 9 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ let package = Package(
name: "NodeModuleSupport",
targets: ["NodeModuleSupport"]
),
.library(
name: "NodeUV",
targets: ["NodeUV"]
),
],
dependencies: [
.package(url: "https://github.com/swiftlang/swift-syntax.git", "600.0.0"..<"602.0.0"),
Expand Down Expand Up @@ -57,6 +61,11 @@ let package = Package(
name: "NodeAPI",
dependencies: ["CNodeAPI", "CNodeAPISupport", "NodeAPIMacros"]
),
.target(name: "CNodeUV"),
.target(
name: "NodeUV",
dependencies: ["CNodeUV"]
),
.target(
name: "NodeModuleSupport",
dependencies: ["CNodeAPI"]
Expand Down
1 change: 1 addition & 0 deletions Sources/CNodeUV/dummy.c
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// empty
34 changes: 34 additions & 0 deletions Sources/CNodeUV/include/cf_stubs.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#ifndef cf_stubs_h
#define cf_stubs_h

#include <stdlib.h>

#if defined(__APPLE__)

#include <mach/message.h>
#include <sys/event.h>

static inline struct kevent64_s node_swift_create_event_descriptor_for_mach_port(mach_port_t port) {
struct kevent64_s ev;
EV_SET64(&ev, port, EVFILT_MACHPORT, EV_ADD|EV_CLEAR, MACH_RCV_MSG, 0, 0, 0, 0);
return ev;
}

typedef mach_port_t dispatch_runloop_handle_t;

#elif defined(__linux__)
typedef int dispatch_runloop_handle_t;
#elif defined(__unix__)
typedef uint64_t dispatch_runloop_handle_t;
#elif defined(_WIN32)
typedef void *dispatch_runloop_handle_t;
#else
#define NODE_SWIFT_NO_GCD_RUNLOOP
#endif

#ifndef NODE_SWIFT_NO_GCD_RUNLOOP
dispatch_runloop_handle_t _dispatch_get_main_queue_port_4CF(void);
void _dispatch_main_queue_callback_4CF(void *unused);
#endif

#endif /* cf_stubs_h */
55 changes: 55 additions & 0 deletions Sources/CNodeUV/include/uv_stubs.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#ifndef uv_stubs_h
#define uv_stubs_h

#ifdef __APPLE__

#include <stdlib.h>

typedef enum {
UV_RUN_DEFAULT = 0,
UV_RUN_ONCE,
UV_RUN_NOWAIT
} uv_run_mode;

typedef enum {
UV_ASYNC = 1,
UV_POLL = 8,
} uv_handle_type;

enum uv_poll_event {
UV_READABLE = 1,
UV_WRITABLE = 2,
UV_DISCONNECT = 4,
UV_PRIORITIZED = 8
};

typedef struct uv_handle_s uv_handle_t;
typedef struct uv_poll_s uv_poll_t;
typedef struct uv_loop_s uv_loop_t;
typedef struct uv_async_s uv_async_t;

typedef void (*uv_async_cb)(uv_async_t *handle);
typedef void (*uv_poll_cb)(uv_poll_t* handle, int status, int events);
typedef void (*uv_close_cb)(uv_handle_t *handle);

size_t uv_handle_size(uv_handle_type type);
void uv_close(uv_handle_t *handle, uv_close_cb close_cb);

uv_loop_t *uv_default_loop(void);
int uv_backend_fd(const uv_loop_t *);
int uv_backend_timeout(const uv_loop_t *);
int uv_run(uv_loop_t *, uv_run_mode mode);
int uv_loop_alive(const uv_loop_t *loop);

int uv_async_init(uv_loop_t *loop,
uv_async_t *async,
uv_async_cb async_cb);
int uv_async_send(uv_async_t *async);

int uv_poll_init(uv_loop_t *loop, uv_poll_t *handle, int fd);
int uv_poll_start(uv_poll_t *handle, int events, uv_poll_cb cb);
int uv_poll_stop(uv_poll_t *handle);

#endif /* __APPLE__ */

#endif /* uv_stubs_h */
181 changes: 181 additions & 0 deletions Sources/NodeUV/NodeCFRunLoop.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#if canImport(Darwin)

import Foundation
import CNodeUV

public enum NodeCFRunLoop {
// would ideally be marked @MainActor but we can't prove that
// MainActor == NodeActor, because the runtime notices that the Node actor
// is active when it runs (although this is also on the main thread...),
// causing MainActor.assumeIsolated to abort.
private nonisolated(unsafe) static var cancelHandlers: (() -> Void)?
private nonisolated(unsafe) static var refCount = 0

public static func ref() {
refCount += 1
// check the mode to determine whether the RunLoop is already running.
// this could be the case if we're in an Electron (or other embedder)
// context. only set up our own loop if it's not running.
if refCount == 1 && RunLoop.main.currentMode == nil {
cancelHandlers = setUp()
}
}

public static func unref() {
if Thread.isMainThread {
_unref()
} else {
Task { @MainActor in _unref() }
}
}

private static func _unref() {
refCount -= 1
if refCount == 0 {
cancelHandlers?()
cancelHandlers = nil
}
}

private static func setUp() -> (() -> Void) {
#if false
// By default, node takes over the main thread with an indefinite uv_run().
// This causes CFRunLoop sources to not be processed (also breaking GCD & MainActor)
// since the CFRunLoop never gets ticked. We instead need to flip things on their
// head: ie use CFRunLoop as the main driver of the process. This is feasible because
// libuv offers an API to "embed" its RunLoop into another. Specifically, it exposes
// a backend file descriptor & timer; we can tell GCD to watch these. Any time they
// trigger, we tick the uv loop once.

// References:
// https://github.com/TooTallNate/NodObjC/issues/2
// https://github.com/electron/electron/blob/dac5e0cd1a8d31272f428d08289b4b66cb9192fc/shell/common/node_bindings.cc#L962
// https://github.com/electron/electron/blob/dac5e0cd1a8d31272f428d08289b4b66cb9192fc/shell/common/node_bindings_mac.cc#L24
// https://github.com/indutny/node-cf/blob/de90092bb65bbdb6acbd0b00e18a360028b815f5/src/cf.cc
// [SpinEventLoopInternal]: https://github.com/nodejs/node/blob/11222f1a272b9b2ab000e75cbe3e09942bd2d877/src/api/embed_helpers.cc#L41

let loop = uv_default_loop()
let fd = uv_backend_fd(loop)

let reader = DispatchSource.makeReadSource(fileDescriptor: fd, queue: .main)
let timer = DispatchSource.makeTimerSource(queue: .main)

nonisolated(unsafe) let wakeUpUV = {
let runResult = uv_run(loop, UV_RUN_NOWAIT)
guard runResult != 0 else { return }

reader.activate()

let timeout = Int(uv_backend_timeout(loop))
if timeout != -1 {
timer.schedule(deadline: .now() + .milliseconds(timeout))
timer.activate()
}
}

reader.setEventHandler { wakeUpUV() }
timer.setEventHandler { wakeUpUV() }
// bootstrap
DispatchQueue.main.async { wakeUpUV() }

// Now that we've set up the CF/GCD sources, we need to
// start the CFRunLoop. Ideally, we'd patch the Node.js
// source to 1) perform the above setup and 2) replace
// its uv_run with RunLoop.run: insertion point would
// be [SpinEventLoopInternal] linked above.
// However, the hacky alternative (while avoiding the need
// to patch Node) is to kick off the CFRunLoop inside the next
// uv tick. This is hacky because we eventually end up with
// a callstack that looks like:
// uv_run -> [process uv_async_t] -> RunLoop.run
// -> [some event uv cares about] -> wakeUpUV -> uv_run
// Note that uv_run is called re-entrantly here, which is
// explicitly unsupported per the documentation. This seems
// to work okay based on rudimentary testing but could definitely
// break in the future / under edge cases.
// TODO: figure out whether there's a better solution.
let uvAsync = OpaquePointer(UnsafeMutableRawPointer.allocate(
// for ABI stability, don't hardcode current uv_async_t size
byteCount: uv_handle_size(UV_ASYNC),
alignment: MemoryLayout<max_align_t>.alignment
))
uv_async_init(loop, uvAsync) { _ in
while NodeCFRunLoop.refCount > 0 && RunLoop.main.run(mode: .default, before: .distantFuture) {}
}
uv_async_send(uvAsync)

return {
reader.cancel()
timer.cancel()
uv_close(uvAsync) { UnsafeMutableRawPointer($0)?.deallocate() }
}
#else
// This is a slightly less intrusive approach than the above,
// since it doesn't replace the existing uv loop (but it's less
// powerful):
//
// It's fragile to try extracting the CFRunLoop's entire waitset,
// but if we're okay with only supporting the GCD bits,
// we can extract GCD's mach port with _dispatch_get_main_queue_port_4CF
// and integrate it into uv. Inspiration:
// https://gist.github.com/daurnimator/8cc2ef09ad72a5577b66f34957559e47
//
// It's theoretically possible to support all of CF this way, but it
// would be fragile. Specifically, we can union the GCD port with
// CFRunLoop->_currentMode->_portSet and create a uv_poll from there
// (like we currently do in this code), and also add a uv_timer_t with
// `CFRunLoopGetNextTimerFireDate`. The issue is that extracting the
// port set is extra fragile due to unstable struct layout.
//
// https://github.com/swiftlang/swift-corelibs-foundation/blob/ae61520/Sources/CoreFoundation/CFRunLoop.c#L3014

let fd = getDispatchFileDescriptor()

let uvPoll = OpaquePointer(UnsafeMutableRawPointer.allocate(
byteCount: uv_handle_size(UV_POLL),
alignment: MemoryLayout<max_align_t>.alignment
))
uv_poll_init(uv_default_loop(), uvPoll, fd)
uv_poll_start(uvPoll, CInt(UV_READABLE.rawValue)) { _, _, _ in
_dispatch_main_queue_callback_4CF(nil)
}

return {
uv_close(uvPoll) {
UnsafeMutableRawPointer($0)?.deallocate()
}
}
#endif
}

// TODO: error handling
private static func getDispatchFileDescriptor() -> CInt {
let port = _dispatch_get_main_queue_port_4CF()

#if canImport(Darwin)
var portset = mach_port_t()
mach_port_allocate(mach_task_self_, MACH_PORT_RIGHT_PORT_SET, &portset)
mach_port_insert_member(mach_task_self_, port, portset)

let fd = kqueue()
let changelist = [node_swift_create_event_descriptor_for_mach_port(portset)]
var timeout = timespec(tv_sec: 0, tv_nsec: 0)
kevent64(fd, changelist, numericCast(changelist.count), nil, 0, 0, &timeout)

return fd
#elseif os(Linux)
// TODO: drop the top level canImport guard
// also support BSD (and Windows if possible)
return CInt(port)
#endif
}
}

#else

public enum NodeCFRunLoop {
public static func ref() {}
public static func unref()
}

#endif
1 change: 1 addition & 0 deletions test/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ let package = Package(
name: suite,
dependencies: [
.product(name: "NodeAPI", package: "node-swift"),
.product(name: "NodeUV", package: "node-swift"),
.product(name: "NodeModuleSupport", package: "node-swift"),
],
path: "suites/\(suite)",
Expand Down
18 changes: 17 additions & 1 deletion test/suites/Test/Test.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import NodeAPI
import NodeUV

@NodeClass @NodeActor final class File {
static let extraProperties: NodeClassPropertyList = [
Expand Down Expand Up @@ -56,6 +57,21 @@ import NodeAPI
try FileManager.default.removeItem(at: url)
return undefined
}

@NodeMethod
func mainActorMethod() async -> String {
await Task { @MainActor in
await Task.yield()
return "Message from main actor"
}.value
}
}

#NodeModule(exports: ["File": File.deferredConstructor])
#NodeModule {
NodeCFRunLoop.ref()
Task { @NodeActor in
try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
NodeCFRunLoop.unref()
}
return ["File": File.deferredConstructor]
}
10 changes: 10 additions & 0 deletions test/suites/Test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ assert.strictEqual(File.default().filename, "default.txt")
const file = new File("test.txt");
assert.strictEqual(file.filename, "test.txt")

// setInterval(() => {
// console.log("hi")
// }, 1000);

;(async () => {
console.log("Getting main actor message");
const msg = await file.mainActorMethod();
console.log("Main actor message:", msg);
})()

let err = "";
try {
file.contents
Expand Down