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

Add Timer and purify the IO API #132

Merged
merged 8 commits into from Feb 28, 2018

Conversation

6 participants
@alexandru
Member

alexandru commented Feb 26, 2018

cats.effect.IO has one great weakness, with these being the symptoms:

  • its reliance on ExecutionContext for triggering async boundaries (e.g. IO.shift), which makes its API in common use to be in fact impure, because reliance on ExecutionContext can affect its referential transparency
  • it has no way to schedule execution with a delay (e.g. Thread.sleep) without reliance on Java's ScheduledExecutorService or on JavaScript's setTimeout
  • this problem is made worse by Scala not providing its own interface, an analogue to ExecutionContext that we could use for delays, with libraries like Monix and FS2 implementing their own Scheduler interface

So the answer to that is currently ¯_(ツ)_/¯

How does Haskell do it?

Haskell provides among others:

  1. threadDelay
  2. getCurrentTime

These operations are simply part of Haskell's Runtime System (RTS), they are simply available. But we need to workaround lacking such a RTS, because we don't have one. The solution in the Monix Task was to require a Scheduler in its runAsync (unsafePerformIO), being Monix's notion of a runtime system, being injected in Task.create for users to use.

But IO's design is simpler, on purpose, so the alternative proposed here is to describe this environment with a pure "Scheduler" alternative, which is supposed to be provided by the runtime...

Timer

trait Timer[F[_]] {

  def currentTime(unit: TimeUnit, tryMonotonic: Boolean = false): F[Long]

  def sleep(duration: FiniteDuration): F[Unit]

  def shift: F[Unit]
}

object Timer {

  def apply[F[_]](implicit timer: Timer[F]): Timer[F] = timer

  def derive[F[_]](implicit F: LiftIO[F], timer: Timer[IO]): Timer[F] = ???
}

Notes:

  1. currentTime is needed as the alternative to System.currentTimeMillis or System.nano and it has the advantage that it can be faked (e.g. in tests) ... if we have a sleep, then we definitely need to know the current time, otherwise we couldn't describe for example "interval at fixed rate" operations 😉
  2. providing a shift operation might seem like complecting two notions, however note that shift can be derived from sleep(zero)

Note that if we have an Timer[IO] available in context, and we do, we can derive an implementation for any F[_] data type that implements LiftIO:

type EitherIO[A] = EitherT[IO, Throwable, A]

val timer = Timer.derive[EitherIO]

Monotonic time

Added a tryMonotonic parameter to currentTime, being a recommendation for the underlying implementation to return a monotonically increasing value. It's a recommendation, as the underlying implementation may or may not support it.

timer.currentTime(NANOSECONDS, tryMonotonic = true)

The default Timer[IO] implementation uses System.currentTimeMillis in case tryMonotonic = false (the default) and it uses System.nanoTime in case tryMonotonic = true. As a matter of implementation detail, as mentioned in the ScalaDoc, the JVM will use CLOCK_MONOTONIC for this operation, when available, instead of CLOCK_REALTIME (see for example clock_gettime() on Linux) and it is up to the underlying platform to implement it correctly.

The difference between nanoTime and currentTimeMillis is that a monotonic clock is more accurate when doing time measurements of execution, because the clock value returned by currentTimeMillis is subject to fluctuations.

This interface does NOT guarantee that a monotonic clock measurement is actually returned, because the JVM itself cannot guarantee that and in fact there are platforms where CLOCK_MONOTONIC is not supported (AFAIK Windows XP is one). And at the moment of writing it's not supported on Node.js / JavaScript — there are possible non-standard solutions, but I'd rather see that make it into Scala.js.

Timer[F] comes from the "Runtime"

To understand this, consider that:

  1. on top of JavaScript a Timer[IO] is simply available (e.g. the new and implicit IO.timerGlobal), because it's based on setTimeout, which is readily available
  2. Monix's Task, due to the way its run-loop works, can describe a pure Timer implementation without any dependencies

On top of the JVM, for Timer[IO] we are doing a trick:

implicit def timer(implicit ec: ExecutionContext): Timer[IO] = ???

So we still need an ExecutionContext in scope for having a Timer, for things to work. But this is related strictly to IO's implementation, on top of the JVM, plus we can always imagine the alternative:

abstract class SafeApp {
  // Part of the environment ;-)
  protected implicit val timer: Timer[IO] = 
    IO.timer(ExecutionContext.Implicits.global)

  abstract def main(args: List[String]): IO[Unit]

  final def main(args: Array[String]): Unit =
    main(args.toList).unsafeRunSync()
}

TestContext

Our cats.effect.laws.util.TestContext implementation has been expanded with super powers, now being able to produce Timer instances and simulate time passing:

val ctx = TestContext()
// Building a Timer[IO] from this:
implicit val timer: Timer[IO] = ctx.timer[IO]

// Can now simulate time
val io = timer.sleep(10.seconds) *> IO(1 + 1)
val f = io.unsafeToFuture()

// This invariant holds true, because our IO is async
assert(f.value == None)

// Not yet completed, because this does not simulate time passing:
ctx.tick()
assert(f.value == None)

// Simulating a 5 seconds delay
ctx.tick(5.seconds)
// Not yet complete
assert(f.value == None)

// Simulating another 5 seconds delay
ctx.tick(5.seconds)
// Done!
assert(f.value == Some(Success(2))

Other Changes ...

IO.shift no longer takes an implicit ExecutionContext:

// Pure version
def shift(implicit timer: Timer[IO]): IO[Unit] = ???

// Old version, for precise fine tuning of the thread-pool
def shift(ec: ExecutionContext): IO[Unit]

Note that this preserves both source and binary compatibility because the JVM doesn't care about implicit parameters, so old compiled bytecode should be fine, plus the new implementation takes a Timer that's also built from the currently available ExecutionContext.

IO.fromFuture no longer takes an implicit ExecutionContext, now the definition being just:

def fromFuture[A](iof: IO[Future[A]]): IO[A]

No reason to require an ExecutionContext when we have our internal TrampolineEC. And now this function too, is pure.

N.B. this has the drawback of being less fair, fairness in scheduling being one of Future's advantages. But there's nothing fair about our cats.effect.IO implementation, async boundaries have to be explicit anyway, so I don't see why fromFuture should be any special.

We also have a short-hand for IO.sleep, to mirror IO.shift:

object IO {
  def sleep(duration: FiniteDuration)(implicit timer: Timer[IO]): IO[Unit] =
    timer.sleep(duration)
}

And so we can do:

val timeout = IO.sleep(10.seconds).flatMap { _ =>
  IO.raiseError(new TimeoutException("10 seconds"))
}

Actual Use-case

Currently in Monix we've got these operations for Iterant that are specialized for Task:

def intervalAtFixedRate(period: FiniteDuration): Iterant[Task, Long] 

def intervalAtFixedRate(initialDelay: FiniteDuration, period: FiniteDuration): Iterant[Task, Long]

def intervalWithFixedDelay(delay: FiniteDuration): Iterant[Task, Long]

def intervalWithFixedDelay(initialDelay: FiniteDuration, delay: FiniteDuration): Iterant[Task, Long]

These are doing what they say, being equivalent with Java's scheduleAtFixedRate and scheduleWithFixedDelay available on your average ScheduledExecutorService.

But I can't describe those for any F[_] and I'd like to do that very much.
This has been the scope of a recent PR: monix/monix#598

The PR introduces Timer in Monix, because that's the only reasonable way to do it. And note Task's purity in this regard:

implicit val forTask: Timer[Task] =
  new Timer[Task] {
    val shift: Task[Unit] =
      Task.shift
    val currentTimeMillis: Task[Long] =
      Task.deferAction(sc => Task.now(sc.currentTimeMillis()))
    def sleep(timespan: FiniteDuration): Task[Unit] =
      Task.sleep(timespan)
  }

But this data type does not belong in Monix, being very generic and useful IMO, belonging in cats-effect.


Cool sample:

import cats.effect._
import cats.syntax.all._
import scala.concurrent.duration._

def repeatAtFixedRate(period: FiniteDuration, task: IO[Unit])
  (implicit timer: Timer[IO]): IO[Unit] = {

  timer.currentTime(MILLISECONDS).flatMap { start =>
    task *> timer.currentTime(MILLISECONDS).flatMap { finish =>
      val nextDelay = period.toMillis - (finish - start)
      timer.sleep(nextDelay.millis) *> repeatAtFixedRate(period, task)
    }
  }
}

Given the latest changes on master for the cancelable IO, this is now doable and pure and we could make this to work with any F[_] ;-)

alexandru added some commits Feb 26, 2018

@lJoublanc

This comment has been minimized.

lJoublanc commented Feb 26, 2018

Would it be possible to use e.g. FiniteDuration in the timer, rather than a canonical millisecond granularity?
Although there may exist issues around the platform being able to provide above-millisecond accuracy in timestamps e.g. across CPUs, it would still be nice to leave the door open for such a platform implementation rather than setting the upper limit at millis.
This would also be useful, like you say, when using a 'test' or 'simulated' scheduler.

@alexandru

This comment has been minimized.

Member

alexandru commented Feb 26, 2018

Maybe, but my problem with FiniteDuration is that it does not express Unix timestamps as a duration since the epoch. It is indeed a tuple made from a Long plus a TimeUnit, but the name is terrible for this use case.

Maybe we could do this instead:

def timeUnit: TimeUnit

def currentTime: F[Long]

And so the user will have to interpret the currentTime in relation to the TimeUnit provided.

This is almost the same thing as providing a FiniteDuration, only better from a contractual point of view, because it forces a Timer instance to always use the same granularity - if you want a different granularity, then a different Timer instance is needed.

@LukaJCB

This comment has been minimized.

Collaborator

LukaJCB commented Feb 26, 2018

Maybe combine the two into def currentTime(unit: TimeUnit): F[Long]?

@mpilquist mpilquist self-requested a review Feb 26, 2018

@alexandru

This comment has been minimized.

Member

alexandru commented Feb 26, 2018

@LukaJCB @lJoublanc changed the interface to your suggestion, it is now:

def currentTime(unit: TimeUnit): F[Long]

So this should make possible an implementation based on System.nanoTime without losing precision by rounding to millis.

@codecov-io

This comment has been minimized.

codecov-io commented Feb 26, 2018

Codecov Report

Merging #132 into master will increase coverage by 1.8%.
The diff coverage is 80%.

@@            Coverage Diff            @@
##           master     #132     +/-   ##
=========================================
+ Coverage   85.71%   87.51%   +1.8%     
=========================================
  Files          31       34      +3     
  Lines         630      689     +59     
  Branches       59       61      +2     
=========================================
+ Hits          540      603     +63     
+ Misses         90       86      -4
@rossabaker

This comment has been minimized.

Member

rossabaker commented Feb 26, 2018

How about def currentTime: F[Instant]? I don't know how much heavier Instant.now is than System.currentTimeMillis for the extra functionality. It would frequently end up being mapped to _.toEpochMilli.

Would it make sense to be able to construct a Timer from a java.time.Clock?

@lJoublanc

This comment has been minimized.

lJoublanc commented Feb 26, 2018

Interestingly I recall Instant is used in scodec-protocols for timestamps.

But it's not a native value. javadoc:

The range of an instant requires the storage of a number larger than a long. To achieve this, the class stores a long representing epoch-seconds and an int representing nanosecond-of-second

Wouldn't this affect performance by creating lots of short-lived objects?

@alexandru

This comment has been minimized.

Member

alexandru commented Feb 26, 2018

@rossabaker @lJoublanc the Instant type looks interesting, but complicated and I'd rather not use it.

The primary use case for this operation is to schedule the execution of tasks. For example it can be used for logic like this:

def repeatAtFixedRate(period: FiniteDuration, task: IO[Unit])
  (implicit timer: Timer[IO]): IO[Unit] = {

  timer.currentTime(MILLISECONDS).flatMap { start =>
    task *> timer.currentTime(MILLISECONDS).flatMap { finish =>
      val nextDelay = period.toMillis - (finish - start)
      timer.sleep(nextDelay.millis) *> repeatAtFixedRate(period, task)
    }
  }
}

And thing is, I don't find very small granularity to be that important. Because for stuff like this:

  1. Thread.sleep can't sleep for less than 1 millisecond (of course, there are other ways to make a thread sleep for nanos, including just doing a busy wait by yourself)
  2. AFAIK the ScheduledExecutorService implementations also don't have a sub-millisecond granularity
  3. on top of JavaScript, as part of the standard, setTimeout has a minimum resolution of 4ms and the minimum resolution for setInterval is specified as 10ms
  4. Akka, for its own Scheduler, clusters tasks in buckets and has an akka.scheduler.tick-duration configured at 10ms

So millisecond granularity is totally fine. The bigger problem is that System.currentTimeMillis (and similar) do not account for leap seconds. It's also not monotonic, versus System.nanoTime which is. But I preferred to go with System.currentTimeMillis as the default because it's faster and because you don't need it to be that accurate usually. And users can always switch to their own implementation.

@rossabaker

Here's a crude benchmark, going to and from Instant and Long:

  def instant(): Instant = IO(Instant.now).unsafeRunSync
  def instantToEpochMilli(): Long = IO(Instant.now).map(_.toEpochMilli).unsafeRunSync
  def currentTime(): Long = IO(unit.convert(System.currentTimeMillis, TimeUnit.MILLISECONDS)).unsafeRunSync
  def currentTimeToInstant(): Instant = IO(unit.convert(System.currentTimeMillis, TimeUnit.MILLISECONDS)).map(Instant.ofEpochMilli).unsafeRunSync
[info] TimeBenchmark.currentTime           thrpt   20  30299441.383 ±  728687.747  ops/s
[info] TimeBenchmark.currentTimeToInstant  thrpt   20  25343281.090 ±  441107.521  ops/s
[info] TimeBenchmark.instant               thrpt   20  28192511.398 ± 1251984.126  ops/s
[info] TimeBenchmark.instantToEpochMilli   thrpt   20  24665220.540 ±  930555.291  ops/s

None of these are slow, and none of the conversions are difficult. Let's optimize for the common case, which is probably Long. I'm fine with it as written.

@alexandru

This comment has been minimized.

Member

alexandru commented Feb 26, 2018

@rossabaker found a really good reason not to use java.time.Instant: not available for Scala.js yet.

Somebody would have to port it.

@mpilquist

This comment has been minimized.

Member

mpilquist commented Feb 26, 2018

@alexandru In the repeat example, consider what happens when elapsed is greater than period making nextDelay negative.

def apply(ec: ExecutionContext, sc: ScheduledExecutorService): Timer[IO] =
new IOTimer(ec, scheduler)

private lazy val scheduler: ScheduledExecutorService =

This comment has been minimized.

@LukaJCB

LukaJCB Feb 26, 2018

Collaborator

The methods inside IOTimer and its companion object don't seem to be flagged by codecov (it's been wrong before), I haven't checked myself, but we should be sure that these are tested, right? :)

This comment has been minimized.

@alexandru

alexandru Feb 26, 2018

Member

Thanks, will check.

This comment has been minimized.

@alexandru

alexandru Feb 27, 2018

Member

I checked and that's just the coverage acting funny. I added some more tests though, Timer.derive for example wasn't covered.

@alexandru

This comment has been minimized.

Member

alexandru commented Feb 27, 2018

@mpilquist that value is simply given to the underlying scheduler, so if it's negative, it does whatever the scheduler wants. In testing it seems to treat it as 0.

@alexandru

This comment has been minimized.

Member

alexandru commented Feb 27, 2018

@rossabaker @lJoublanc I have now added a new parameter to currentTime, the signature now looks like this:

def currentTime(unit: TimeUnit, tryMonotonic: Boolean = false): F[Long]

When tryMonotonic is true, the Timer[IO] implementation uses System.nanoTime. This can be more useful for measuring how long it took to execute a task, on platforms that actually support it, because currentTimeMillis is subject to fluctuations.

Let me know what you think, as I'd like to see this PR merged, so I can unstuck my Monix issue 😀

@lJoublanc

This comment has been minimized.

lJoublanc commented Feb 27, 2018

@alexandru Thanks for taking my concern into account. My main gripe was not to have millis as the upper bound on granularity. I'm not too opinionated about the implementation. You make very valid points about granularity of timers e.g. sleep. My use-case is more than anything timestamping in streaming libraries, i.e. being able to record stuff, and possibly play it back in order - often millisecond res is not granular enough to do this. Thanks for your understanding!

@rossabaker

This comment has been minimized.

Member

rossabaker commented Feb 27, 2018

The optional boolean to currentTime feels like a smell to me. Maybe a separate method, monotonicTime? Though then it loses the "try".

Since it's only useful for elapsed time, my gut instinct is to make something like a def timed[F[_]: Monad, A](fa: F[A]): F[(A, FiniteDuration)], to steer people to only using it for a duration. But those binds aren't free when we're measuring nanoseconds, and it's probably too prescriptive. Meh.

I'm okay with it as is. Just thinking out loud.

@alexandru

This comment has been minimized.

Member

alexandru commented Feb 27, 2018

@rossabaker measuring execution time is the recommended use-case in the description of System.nanoTime, however other people might want for example to attach timestamps to streaming events and for which ordering is more important than equivalence to wall-time.

So lets do two methods then. Because these correspond to CLOCK_REALTIME and CLOCK_MONOTONIC returned by clock_gettime() on Linux. And we can say in the documentation that CLOCK_MONOTONIC has to be supported by the underlying platform. If not, then the behavior is that of CLOCK_REALTIME.

def clockRealTime(unit: TimeUnit): F[Long]

def clockMonotonic(unit: TimeUnit): F[Long]

So should we do this?

@alexandru

This comment has been minimized.

Member

alexandru commented Feb 27, 2018

What I mean is, if we can't name things, we'll just go to the source 😀

@rossabaker

This comment has been minimized.

Member

rossabaker commented Feb 27, 2018

I like that interface better, and the ordering point is good. 👍

@mpilquist mpilquist merged commit fae1392 into typelevel:master Feb 28, 2018

1 check passed

continuous-integration/travis-ci/pr The Travis CI build passed
Details
@lJoublanc

This comment has been minimized.

lJoublanc commented Mar 1, 2018

I'm going to have to eat my own words about not caring how this is implemented ... I've been mulling over this and I think there are two compelling reasons why splitting the clock in realtime/monotonic is bad:

Portability

Consider what happens if you write a program on JVM with the above API and then also compiling it into javascript. What would happen to calls to clockMonotonic in js?

  1. It could either be 'demoted' to clockRealtime. This would probably work 99% of the time, but in the other 1% of the time would introduce very subtle bugs, hard to track down.
  2. It could throw a 'Not supported' error and fail-fast. This would only be picked up at runtime though. Again, bad for portability.

API Bloat

Making an assumption, based on the number of github stars, that cats-effect is typically used on top of a streaming library e.g. monix/fs2 the library authors @alexandru @mpilquist ,will need to choose whether to widen their API to reflect changes, or pick between clockRealTime and clockMonotonic in their implementation . In the former case, this will lead to a more complex API, and again, I'm making a hand-wavy assumption here, but I suspect that the majority of users won't care about timer accuracy .. they'll be wondering "what's this monotonic flag for"? And some might inadvertently use it, leading to silent bugs in the portability section above.
However in the later case, if they default all calls to e.g. clockRealTime, then this will defeat the whole purpose of splitting currentTime in two. The end-user won't have access to those calls through the streaming APIs, without forking the library and changing the underlying calls.

Original solution

I think there should be a separation of concerns - each time-source should have it's own instance. I would suggest going back to the original design of a single currentTime:

def currentTime(unit: TimeUnit): F[Long]

And make sure that the behaviour i.e. monotonic/non-monotonic, resolution, and accuracy/error, be clearly documented, or even implemented as final vals in the Timer class.

The javascript implementation could have a default Timer instance (based on currentTimeMillis), and the JVM could in addition have a second, TimerMonotnic instance. This way, the behaviour of library functions would be determined by the context i.e. Timer in use, and could be overridden by the user. Also, this would leave the door open for other, platform specific implementations, as there are all sorts of issues around time sources (see link in my first post, or have a look at clock_gettime - there are options for CPU and thread-specific time there, too)

I'd be happy to do a PR if this hasn't been finalized?

@alexandru

This comment has been minimized.

Member

alexandru commented Mar 1, 2018

For JavaScript the call to clockMonotonic also uses System.nanoTime, which as I found, it does make an attempt to use high precision time. Here's the relevant code:

  private[this] val getHighPrecisionTime: js.Function0[scala.Double] = {
    import js.DynamicImplicits.truthValue

    if (js.typeOf(global.performance) != "undefined") {
      if (global.performance.now) {
        () => global.performance.now().asInstanceOf[scala.Double]
      } else if (global.performance.webkitNow) {
        () => global.performance.webkitNow().asInstanceOf[scala.Double]
      } else {
        () => new js.Date().getTime()
      }
    } else {
      () => new js.Date().getTime()
    }
}

So it tries using:

Most importantly however is that these functions DO NOT use the Unix epoch as the time origin. Rather the time origin in JavaScript is usually the moment the Browser's context was created, see MDN document.

Why I really like our new design is because clockMonotonic no longer has a contract to return the time units since the Unix epoch, the time origin being chosen by the underlying implementation, its only contract being for the difference between two time measurements to be as accurate as possible.

This is in fact the contract of System.nanoTime too, even if right now OpenJDK 8 does in fact return the number of nanos since the Unix epoch:

The value returned represents nanoseconds since some fixed but arbitrary origin time (perhaps in the future, so values may be negative). The same origin is used by all invocations of this method in an instance of a Java virtual machine; other virtual machine instances are likely to use a different origin.

Or in other words, if you really think about it, if you have a currentTime method with the contract of currentTimeMillis, then changing its implementation to System.nanoTime does violate its contract, because the value returned has totally different semantics.

It is no longer real-time. It is no longer a Unix timestamp!

It's also the correct way of doing execution time measurements, because currentTimeMillis can go back in time, meaning that ...

val t1 = System.currentTimeMillis()
val t2 = System.currentTimeMillis()

// This is not always true!
assert(t1 <= t2)
@alexandru

This comment has been minimized.

Member

alexandru commented Mar 1, 2018

I also don't think at this point that this is API bloat.

The two methods correspond to CLOCK_REALTIME, aka System.currentTimeMillis and CLOCK_MONOTONIC, aka System.nanoTime, which are the two methods of doing clock measurements that are available to use on the JVM (and apparently on top of JavaScript as well). That clock_gettime() supports other values, those aren't accessible to us.

Note that I already have a PR in Monix that integrates with Timer: monix/monix#598 - and clockMonotonic is useful because I want to express this operation:

def intervalAtFixedRate[F[_]](period: FiniteDuration)
  (implicit F: Async[F], timer: Timer[F]): Iterant[F, Long]

Note that there's actually no instance in which System.currentTimeMillis is more correct than System.nanoTime for this one. This is NOT a callsite decision that the user should make, this is an implementation issue.

As to why a monotonic clock is needed in such cases, this blog post says it better:

Perhaps less often considered is that Date, based on system time, isn't ideal for real user monitoring either. Most systems run a daemon which regularly synchronizes the time. It is common for the clock to be tweaked a few milliseconds every 15-20 minutes. At that rate about 1% of 10 second intervals measured would be inaccurate.

That happens with System.currentTimeMillis too and it's actually great that we've had this conversation, as it made me to investigate this area and change all execution measurements in Monix to clockMonotonic (in that PR).

@lJoublanc

This comment has been minimized.

lJoublanc commented Mar 1, 2018

or JavaScript the call to clockMonotonic also uses System.nanoTime,

I wasn't aware of this .. I don't know the js platform very well, and should have checked. I stand corrected 😊 That makes the first problem go away.

Re your second post, I agree with you on intervalAtFixedRate.

This is NOT a callsite decision that the user should make, this is an implementation issue.

Yes, I'm sold on your argument, thinking about it now. I was thinking more of a method I can't find in the Monix API (at a quick glance): Observable.zipWithTimestamp this is what I was thinking about in my discussion above ..
aside : fs2 has Scheduler.awakeEvery and friends. But looking at this now it returns the duration since called, not an absolute timestamp, so that's safe too.
When timestamping, nanoTime isn't guaranteed to give you an absolute time (as you probably know it's only safe to take the diff of two nanoTime calls, to measure time elapsed), without somehow 'calibrating' it with the system clock. Even if you do this, similar to the RTC, you can get skew if you let it run for long enough. There's all sort of problem around this. So this is what I thought should be left up to the user to implement - however, now I realize that streaming fs2/monix don't provide any 'timestamping' methods (and probably they shouldn't), so you put some of my fears to rest.

If you're interested in time sources and synchronization in the JVM, here is an interesting project which tries to implement this across threads using JNA.

@alexandru alexandru referenced this pull request May 10, 2018

Closed

Timer as a Resource #207

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment