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

Support macOS via kqueue #27

Merged
merged 17 commits into from
Sep 10, 2022
Merged

Support macOS via kqueue #27

merged 17 commits into from
Sep 10, 2022

Conversation

armanbilge
Copy link
Owner

Closes #2.

@LeeTibbert
Copy link
Collaborator

Wow!

if (LinktimeInfo.isLinux)
socket.accept4(fd, null, null, SOCK_NONBLOCK)
else
socket.accept(fd, null, null)
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems the SN binding for accept is still bugged with null arguments, so I call our own binding here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems the SN binding for accept is still bugged with null arguments,

That is both news and a disappointment. I will look into that in daylight.
This almost surely falls into the category of code that I got rid of for 0.5.0,
but that does us no good for 0.4.n.

I am surprised by the null arguments. I can trace the code, but I would expect
the enclosing method to actually use the accepted sockaddr to fill in the
Socket structure returned. I need to look at more than a four line window.
All things in time.

@armanbilge armanbilge marked this pull request as ready for review September 6, 2022 03:45
@armanbilge
Copy link
Owner Author

CI is starting to look green, but there are still some suspicious behaviors to be investigated...

Lee, I trust that you will scrutinize this on M1 😁 thanks!

Comment on lines 82 to 87
if ((event.flags.toLong & EV_ERROR) != 0) {

// TODO it would be interesting to propagate this failure via the callback
reportFailure(new RuntimeException(s"kevent64: ${event.data}"))

} else if (callbacks.contains(event.ident.toLong)) {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suspicious behavior here: in the CI logs it seems that a failure here is being reported quite frequently. However, the value of event.data is zero. This seems strange because the manpages say that

with EV_ERROR set in flags and the system error in data.

https://keith.github.io/xcode-man-pages/kqueue.2.html

Copy link
Owner Author

@armanbilge armanbilge Sep 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, I do not see these on my local system, which is macOS 11 intel.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, I lied, I do see it on my local 😂

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I had an off-by-one, which is why data was 0.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So for the record the error is a "bad file descriptor". What's more interesting is it only seems to happen when running multiple tests; I cannot reproduce it when running a single test in isolation. This suggests some kind of interference perhaps due to file descriptors being re-used?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard to tell from the audit trail above, sorry. Is this soup enough for me to try on Apple M1?
Not rushing, just polling.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, please, I think it's better to try on M1 sooner rather than later :) All the tests should pass, but there will likely be some stack traces logged.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I am off to run this PR on M1. That will keep me out of your hair for awhile.

@LeeTibbert
Copy link
Collaborator

2022-09-06 12:20 -0400

testsJVM/test : all passed, establishing a baseline

testsNative/test :
The really concerning part is that no tests failed
even though sevaral have tracebacks with errors I would
consider severe enough to stop the test.

[info] Passed: Total 12, Failed 0, Errors 0, Passed 12

Apple M1, Monterrey, most current os (12.6?).

I will print full stack traceback for the first and
then just the Exception for the other failing tests.

I think -9 is EBADF (bad file descriptor) on macOS.
Hard to tell from web search. I can write a small
program to confirm if that would be useful.

Unlisted tests all pass cleanly.

I will have to use my system for awhile to see if it is OK
after the kexec messing around & errors.
Safari is wonky, but then it is always wonky.

  • server-client ping-pong 0.00s
    java.lang.RuntimeException: kevent64: flags=4021 errno=9
    at java.lang.StackTrace$.currentStackTrace(Unknown Source)
    at java.lang.Throwable.fillInStackTrace(Unknown Source)
    at java.lang.RuntimeException.(Unknown Source)
    at epollcat.unsafe.KqueueExecutorScheduler.poll(Unknown Source)
    at cats.effect.unsafe.PollingExecutorScheduler.loop(Unknown Source)
    at cats.effect.unsafe.PollingExecutorScheduler.scheduleIfNeeded$$anonfun$1(Unknown Source)
    at cats.effect.unsafe.PollingExecutorScheduler$$Lambda$2.run(Unknown Source)
    at scala.scalanative.runtime.ExecutionContext$.loop(Unknown Source)
    at scala.scalanative.runtime.package$.loop(Unknown Source)
    at scala.scalanative.testinterface.NativeRPC.loop(Unknown Source)
    at scala.scalanative.testinterface.TestMain$.main(Unknown Source)
    at scala.scalanative.testinterface.TestMain.main(Unknown Source)
    at .main(Unknown Source)
    java.lang.RuntimeException: kevent64: flags=4021 errno=9
    at java.lang.StackTrace$.currentStackTrace(Unknown Source)
    at java.lang.Throwable.fillInStackTrace(Unknown Source)
    at java.lang.RuntimeException.(Unknown Source)
    at epollcat.unsafe.KqueueExecutorScheduler.poll(Unknown Source)
    at cats.effect.unsafe.PollingExecutorScheduler.loop(Unknown Source)
    at cats.effect.unsafe.PollingExecutorScheduler.scheduleIfNeeded$$anonfun$1(Unknown Source)
    at cats.effect.unsafe.PollingExecutorScheduler$$Lambda$2.run(Unknown Source)
    at scala.scalanative.runtime.ExecutionContext$.loop(Unknown Source)
    at scala.scalanative.runtime.package$.loop(Unknown Source)
    at scala.scalanative.testinterface.NativeRPC.loop(Unknown Source)
    at scala.scalanative.testinterface.TestMain$.main(Unknown Source)
    at scala.scalanative.testinterface.TestMain.main(Unknown Source)
    at .main(Unknown Source)

  • read after shutdownInput 0.00s
    java.lang.RuntimeException: kevent64: flags=4021 errno=9

  • ConnectException 0.00s
    java.lang.RuntimeException: kevent64: flags=4021 errno=9

  • EOF -

@armanbilge
Copy link
Owner Author

Thanks for reporting those results! Just to confirm, if you run those tests in isolation (e.g. test("abc".only) { ... }) do you still get those stack traces?

I think -9 is EBADF (bad file descriptor) on macOS.
Hard to tell from web search.

Yes I believe it is.

The really concerning part is that no tests failed
even though sevaral have tracebacks with errors I would
consider severe enough to stop the test.

Eh, I am not so concerned. I believe these errors are occurring because the file descriptors have not been deregistered from the kqueue after the socket has closed. I would like to fix it, but there seems to be some interference when new sockets are opened, possibly reusing said descriptors?

@LeeTibbert
Copy link
Collaborator

Thanks for reporting those results! Just to confirm, if you run those tests in isolation (e.g. test("abc".only) { ... }) do you still get those stack traces?

I did not try one-at-a-time. I can try that later tonight when it cools down.

but there seems to be some interference when new sockets are opened, possibly reusing said descriptors?

Yes, unix likes to las-in-first-out file descriptor it knows about. Thus if you open an fd, getting say fd4, then close 4,
then open again, one will tend to get fd 4. Dumb! but ancient. SO_REUSEADDR (and probably SO_REUSEPORT where
available) are necessary/well_advised here, but may be causing the immediate re-use of the fd. I would have
expected a pause.

@LeeTibbert
Copy link
Collaborator

Eh, I am not so concerned.

I am not following. I understand that rules are off/suspended for rapid devo, so
I will aim for 'release' conditions. Why would a traceback in a test, no matter what
the cause, not cause it to fail? Seems like exactly the kind of thing one would want
CI to detect & report.

@armanbilge
Copy link
Owner Author

Why would a traceback in a test, no matter what
the cause, not cause it to fail?

Good question. The error is happening here:

while (i < triggeredEvents) {
if ((event.flags.toLong & EV_ERROR) != 0) {
// TODO it would be interesting to propagate this failure via the callback
reportFailure(
new RuntimeException(
s"kevent64: flags=${event.flags.toHexString} errno=${event.data}"
)
)

Currently there is no way to propagate this error back via the callback, so we resort to reportFailure (which prints to stderr). Indeed, in this case the callback does not even exist anymore—the original socket has been closed.

Part of my confusion is that supposedly calling close() on a file descriptor should remove it from the kqueue automatically.

Calling close() on a file descriptor will remove any kevents that reference the descriptor.

https://www.freebsd.org/cgi/man.cgi?query=kqueue&sektion=2

In an earlier draft of the KqueueExecutionContext I was actually explicitly removing the kevents when the socket closed:

case Unmonitor(fd, cb) =>
change.ident = fd.toULong
change.flags = EV_DELETE.toUShort
callbacks.remove(cb)

However, this was also causing kqueue to error, in this case because I had asked it to remove kevents that no longer existed, because it had removed them itself when I closed the socket!!

So I am a bit stumped about what exactly to do here.


Note that it is standard convention to use reportFailure when there is an error encountered in an ExecutionContext, unless the error is fatal to the ExecutionContext itself. For example:
https://github.com/scala-native/scala-native/blob/4d5c65d753068f08c8de5636e4b33863f6e77ec9/nativelib/src/main/scala/scala/scalanative/runtime/ExecutionContext.scala#L17-L25

@LeeTibbert
Copy link
Collaborator

I tried running a single test on Apple M1. As you surmised, it worked as expected, with no traceback.

You seemed to indicate that there was an easy way to run one test within a Suite. I know how to run
a single Suite, how does one run a single test?

I usually use brute force and edit the file. Knowing how to do a single test would save
me time, which I could then use that to provoke other bugs. Thanks.

I will standby. Let me know how I can be useful. It will be a long day or
so before I can trace the kexec code.

@armanbilge
Copy link
Owner Author

Thanks for confirming that! So now we think what to do ...


I know how to run a single Suite, how does one run a single test?

Unfortunately I also edit the file 😂 I usually do this:

test("run this test".only) {
  ...
}

There are also some hints on how to run a single test without editing it:
https://scalameta.org/munit/docs/filtering.html

@armanbilge
Copy link
Owner Author

Ok, I've now fixed the EBADF error that was logging those stack traces. In the end it was just my stupidity :)

Unlike epoll, new event registrations for kqueue are batched and submitted together at the next opportunity in the run loop. What I didn't account for was that in some situations, a socket could be opened and closed within a single iteration before it had even been registered. So a now-stale registration for this socket was still sitting in the batch queue unaware that its socket had been closed, and would be executed in the next iteration, causing the error.

The easy fix is to add a canceled marker to the registration. But I'm open to other ideas too.

Now that CI is a healthy green, I think this PR is ready for proper review 😁

@LeeTibbert
Copy link
Collaborator

I have only a moment now. I was looking at the most recent changes and
thinking about the kexec tracebacks you/we have been chasing.
When I went through the socket close changes, I wondered
"What would happen if one got both a read event and a write event
(or other 2fer combinations) on the same socket during a given
kexec interval? Would the socket be closed twice? Normally
that is not a bad thing, but would it cause kexec to attempt
removal twice, failing the second time with "bad fd"?)

I am offline for most of today. I will try to trace that when
I become available today.

Not that I doubt your code, just that I like to do fault
injection thought experiments.

@armanbilge
Copy link
Owner Author

Not that I doubt your code, just that I like to do fault
injection thought experiments.

Yes please!! I'm counting on it 😁

"What would happen if one got both a read event and a write event
(or other 2fer combinations) on the same socket during a given
kexec interval? Would the socket be closed twice?

Sorry, I couldn't follow this scenario. How do read and write events relate to closing the socket?

@LeeTibbert
Copy link
Collaborator

I am working my way through the code. I'll look closely at the "2 event in poll interval". When I first read the
code a couple of days ago, I twitched at a section that looked like it could cause a double close. More if/when
I have evidence in line numbers.

Switching topics:
In the Accept method, near Line 154:

        try {
// I think that accept4() has already setNonBlocking in the Linux case
          SocketHelpers.setNonBlocking(clientFd)
          handler.completed(EpollAsyncSocketChannel.open(clientFd), attachment)
        } catch {

I am somewhat surprised that accept is fetching the local address information while it has it at hand.
I can't see where it would put it, but I would have expected the calling socket to want that info. Something
for me to ponder as I wander through the code.

@armanbilge
Copy link
Owner Author

setNonBlocking is a no-op on Linux:

def setNonBlocking(fd: CInt): Unit = if (!LinktimeInfo.isLinux) {
if (posix.fcntl.fcntl(fd, posix.fcntl.F_SETFL, posix.fcntl.O_NONBLOCK) != 0)
throw new IOException(s"fcntl: ${errno.errno}")
}

But perhaps I should rename setNonBlockingIfNeeded, or move the condition outside.

@armanbilge
Copy link
Owner Author

Good question. It is a bit of a hack, and I am afraid we have to keep it; otherwise our tests would break. I wrote a detailed explanation in Cats Effect.

https://github.com/typelevel/cats-effect/blob/2b3bc18b2eb3456d7ae263e414c716341ce5b05d/core/native/src/main/scala/cats/effect/unsafe/PollingExecutorScheduler.scala#L82-L93

  /**
   * @param timeout
   *   the maximum duration for which to block. ''However'', if `timeout == Inf` and there are
   *   no remaining events to poll for, this method should return `false` immediately. This is
   *   unfortunate but necessary so that this `ExecutionContext` can yield to the Scala Native
   *   global `ExecutionContext` which is currently hard-coded into every test framework,
   *   including JUnit, MUnit, and specs2.
   *
   * @return
   *   whether poll should be called again (i.e., there are more events to be polled)
   */
  protected def poll(timeout: Duration): Boolean

@LeeTibbert
Copy link
Collaborator

LeeTibbert commented Sep 8, 2022

Everything is "art of the possible'.

Thank you for the explanation and for leaving something for me to alert on.

Perhaps a one line comment in the file referencing the longer explanation?
Or are you hoping people will not notice it, nestled in the rest of the complexity?

Aren't "Infinity - 1" or even a minute or 10 going to cause the same problem?

@armanbilge
Copy link
Owner Author

Adding a comment is a good idea. I actually forgot about this myself and tried to remove this "weird" case in 902b939. Then I reverted in 8e07a9b. So ... yeah :)


Aren't "Infinity - 1" or even a minute or 10 going to cause the same problem?

They are not. If a non-infinite value is passed to poll, then that means Cats Effect is still in control: it has a timer that is scheduled to fire, and thus poll should take no longer than that.

When Cats Effect has no scheduled timers, it will pass an infinite timeout to poll, so that poll will only wake in response to I/O events

The problem occurs when Cats Effect has no scheduled timers (so it passes an infinite timeout) and there are no outstanding I/O events to poll for. In essence, the user application has finished: there is nothing left to do. Specifically, if we are running in a test suite, the test has completed. Thus it is imperative that we immediately yield control back to the test runner, which is running on the Scala Native global ExecutionContext.

@LeeTibbert
Copy link
Collaborator

Clever! Thank you for patiently explaining the contract.

@armanbilge
Copy link
Owner Author

Not at all, I appreciate your detailed tracing of the implementation. Every line of code that you question let's me sleep better at night knowing that at least one person other than myself has stared at it!

@LeeTibbert
Copy link
Collaborator

I can now confirm that this PR runs multiple tests on macOS using M1 hardware.
I saw no errors & no hangs. Nice job!

I think this PR is ready for merge. I can continue to trace code, but I think
that is followup.

I think the step after that is for me to see if I can break it by doing my IPv6
studies on Apple M1.

@@ -27,10 +27,13 @@ jobs:
name: Build and Test
strategy:
matrix:
os: [ubuntu-latest]
os: [ubuntu-latest, macos-latest]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A matter of personal style:

I have been hosed a number of times by using foo-latest rather than, say ubuntu-20.04.

foo-latest is good for rapid prototyping but fragile going beyond that .

GitHub can and does change the -latest. Things can go
bump in the night where the shrapnel leaves little direct clue.

Yes, this introduces the technical debt of having to manually
change all the ubuntu-18.04 to ubuntu-20.04 and to
figure out what the desired -mumble is.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, absolutely agree. Let's get these pinned down!

@@ -9,6 +9,9 @@ ThisBuild / tlSonatypeUseLegacyHost := false
ThisBuild / crossScalaVersions := Seq("3.1.3", "2.12.16", "2.13.8")

ThisBuild / githubWorkflowJavaVersions := Seq(JavaSpec.temurin("17"))
ThisBuild / githubWorkflowOSes += "macos-latest"
ThisBuild / githubWorkflowBuildMatrixExclusions +=
MatrixExclude(Map("os" -> "macos-latest", "project" -> "rootJVM"))

val catsEffectVersion = "3.4-519e5ce-SNAPSHOT"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are catsEffectVerson and the line below it still up to date.
There have been a couple of version up/down(?) grades
recently. Good reasons to get this PR in.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, they have fallen out of date. Fortunately CI runs on a merge commit so it will use the latest from main.

.socket(posix.sys.socket.AF_INET, posix.sys.socket.SOCK_STREAM | SOCK_NONBLOCK, 0)
if (fd == -1)
throw new RuntimeException(s"socket: ${errno.errno}")
val fd = SocketHelpers.mkNonBlocking()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will help my IPv6 work, thank you.

@armanbilge
Copy link
Owner Author

I pinned the OS versions, and expanded the matrix to cover ubuntu 20.04 and 22.04 and macOS 11 and 12. This should have us well covered.

@extern
@nowarn
private[ch] object socket {
final val SOCK_NONBLOCK = 2048
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps one line to state which operating systems use
this magic value, Linux, macOS, FreeBSD?

Somebody is going to port to an OS which does not.

Copy link
Owner Author

@armanbilge armanbilge Sep 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, even I will forget and make a mistake.

I noted that SOCK_NONBLOCK and also accept4 are supported on Linux and FreeBSD, however we are currently only using them on Linux. Because we do not have a FreeBSD environment in CI I am hesitant to add special conditions for it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re: FreeBSD understood & concur.

@@ -43,7 +42,7 @@ private[unsafe] final class EpollExecutorScheduler private (
val noCallbacks = callbacks.isEmpty()

if ((timeoutIsInfinite || timeout == Duration.Zero) && noCallbacks)
false // nothing to do here
false // nothing to do here. refer to scaladoc on PollingExecutorScheduler#poll
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. Given the "refer to", even I could probably find that
(and will chase it down for my own edification).

@extern
@nowarn
private[unsafe] object event {

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the provenance of these magic numbers?
Are they macOS only or expected to work on any *BSD,
say FreeBSD?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are from here:
https://opensource.apple.com/source/xnu/xnu-7195.81.3/bsd/sys/event.h.auto.html

I do not know of their support beyond macOS. epollcat does not officially support FreeBSD or other BSDs. Lacking both a local machine and CI environment for it makes it difficult to support at this time. I'm happy to wait for a request.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest adding exactly that URL and a "Your mileage may vary on FreeBSD and others".
We have concurred before about "one thing done well" rather than "a skillion things done wrong".

I know the pain about doing primary development only on CI machines. I give my SN PRs
on Windows two attempts, one to generate to smoke, and, if only a little, one to see if I can hack a fix"
Meanwhile, I utter words which, sad to admit, increase my karmic debt.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the chuckle 😁 I added the URL in e24bd4a.


callbacks(fd.toLong) = cb

() => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment is well placed. It anticipates & answers
the question of a person tracing the code.

Can it be expanded just a bit? Are you saying that
at that point the fd has been closed (by somebody), and
that notified kexec to un-register it.

Or are you saying that callbacks.remove() will close the fd?
I am now tracing callbacks. It looks like a Set and it would
be mildly surprising for a Set.remove to close/transform the thing being removed.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes I see it is confusing as-written. It is the former: we do not need to anything special to unregister the fd with the kqueue, because it will be unregistered automatically when the fd is closed by its owner.

The purpose of callbacks is to prevent them from being garbage-collected while the kqueue holds the pointer, which the GC doesn't see. I will add a comment to clarify that as well.

@LeeTibbert
Copy link
Collaborator

There is a line in AsyncServerSocketChannel which I do not understand.

 ch.unmonitor = epoll.monitor(fd, reads = true, writes = false)(ch)

The writes = false part drew my attention during review.
Seems to me that there are at least two cases where somebody
could write to that socket and the monitor would want to know.
Agreed, few if any code should have access to that fd, but
thinking in the general case.

  1. A blatant error on somebodies part that ought to be
    detected and reported.

  2. A "shutdown" or "poll interval change (epollctl)" message.
    Now, this definitely falls into the category of "Hard lock the gate
    (write = false) now, for surety, and let people adding extensions pay the
    cost of adding changes when they do it.

@LeeTibbert
Copy link
Collaborator

LeeTibbert commented Sep 10, 2022

LGTM. Job well done

All I could find were low level concerns & slight improvements. Thank
you for listening & responding so quickly.

At this point, I think wall clock time of experience is needed to detect remaining
issues, if any.

@armanbilge
Copy link
Owner Author

The writes = false part drew my attention during review.
Seems to me that there are at least two cases where somebody
could write to that socket and the monitor would want to know.
Agreed, few if any code should have access to that fd, but
thinking in the general case.

Oh interesting, I actually did not know this was possible at all! So continuing this line: if we were to register the callback to be notified for write events as well, what should it do if it's triggered? Raise an error?

@armanbilge
Copy link
Owner Author

I think what's confusing to me about subscribing to write events, is that it's not notifying us that someone/something has written to the socket. It is notifying us that the socket is ready to be written to. What would we do with that information?

@LeeTibbert
Copy link
Collaborator

I think what's confusing to me about subscribing to write events, is that it's not notifying us that someone/something has written > to the socket. It is notifying us that the socket is ready to be written to. What would we do with that information?

You are absolutely right! My mistake.

@armanbilge
Copy link
Owner Author

Lee, thank you so much for your detailed review. I feel very lucky to have your wisdom, experience, and humor on the team. I learned a lot, and will continue to do so! :)

@armanbilge armanbilge merged commit dae8f73 into main Sep 10, 2022
@armanbilge armanbilge deleted the pr/macos branch September 10, 2022 13:55
@LeeTibbert
Copy link
Collaborator

Arman, thank you for the encouragement and kind words.

I am enjoying the collaboration and am energized by both
your support and the opportunity to learn.
Good, wholesome environment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

macOS support via kqueue
2 participants