From dc6bca2665eab8a96c368bec66836e4b941afbf4 Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Mon, 21 Jul 2025 19:11:34 +0300 Subject: [PATCH 1/7] Fix warnings --- drift_js/src/drift/js/channel.gleam | 1 - drift_js/test/drift_js_test.gleam | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/drift_js/src/drift/js/channel.gleam b/drift_js/src/drift/js/channel.gleam index 17dd507..081e74d 100644 --- a/drift_js/src/drift/js/channel.gleam +++ b/drift_js/src/drift/js/channel.gleam @@ -2,7 +2,6 @@ //// Provided for convenience, not required to use `drift_js`. import gleam/javascript/promise.{type Promise} -import gleam/result /// An unbounded single-consumer channel with synchronous sending /// and asynchronous receiving. diff --git a/drift_js/test/drift_js_test.gleam b/drift_js/test/drift_js_test.gleam index 5ca126e..8c81c50 100644 --- a/drift_js/test/drift_js_test.gleam +++ b/drift_js/test/drift_js_test.gleam @@ -133,7 +133,7 @@ fn stop(ctx: Context(Bool, o), state: s, stop: Bool) -> Step(s, Bool, o, e) { } } -pub fn start_with_action_executor( +fn start_with_action_executor( state: s, next: fn(Context(i, Action(a)), s, i) -> Step(s, i, Action(a), e), ) -> #(Promise(TerminalResult(s, e)), Runtime(i)) { From 45dbd0e2c6472c44bcc1236ad6e81f25e486b28a Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Mon, 21 Jul 2025 19:23:38 +0300 Subject: [PATCH 2/7] Add test for scheduling between multiple runtimes. --- drift_js/test/drift/shceduling_test.gleam | 66 +++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 drift_js/test/drift/shceduling_test.gleam diff --git a/drift_js/test/drift/shceduling_test.gleam b/drift_js/test/drift/shceduling_test.gleam new file mode 100644 index 0000000..d864ec0 --- /dev/null +++ b/drift_js/test/drift/shceduling_test.gleam @@ -0,0 +1,66 @@ +import drift +import drift/js/channel.{type Channel} +import drift/js/runtime +import gleam/javascript/promise.{type Promise} +import gleam/list + +type State { + State(value: Int, remaining_sends: Int) +} + +// Checks that a single runtime that sends messages to itself repeatedly +// still allows other runtimes to run. +pub fn multiple_runtime_scheduling_test() -> Promise(Nil) { + let results = channel.new() + let #(termination1, runtime1) = + runtime.start(State(1, 5), fn(_) { results }, handle_input, handle_output) + let #(termination2, runtime2) = + runtime.start(State(2, 5), fn(_) { results }, handle_input, handle_output) + + // Kick off the event handling + runtime.send(runtime1, Nil) + runtime.send(runtime2, Nil) + + // Wait for termination + use _ <- promise.await(termination1) + use _ <- promise.await(termination2) + + assert channel_to_list(results, []) == [1, 2, 1, 2, 1, 2, 1, 2, 1, 2] + + promise.resolve(Nil) +} + +fn handle_output( + context: drift.EffectContext(channel.Channel(Int)), + value: Int, + send: fn(Nil) -> Nil, +) -> Result(drift.EffectContext(channel.Channel(Int)), Nil) { + // Send who was scheduled to results + let results = drift.read_effect_context(context) + channel.send(results, value) + + // Trigger another input to be handled + send(Nil) + Ok(context) +} + +fn handle_input( + ctx: drift.Context(a, Int), + state: State, + _, +) -> drift.Step(State, a, Int, c) { + case state.remaining_sends { + 0 -> drift.stop(ctx, state) + remaining_sends -> + ctx + |> drift.output(state.value) + |> drift.continue(State(..state, remaining_sends: remaining_sends - 1)) + } +} + +fn channel_to_list(channel: Channel(a), values: List(a)) -> List(a) { + case channel.try_receive(channel) { + Ok(value) -> channel_to_list(channel, [value, ..values]) + Error(Nil) -> list.reverse(values) + } +} From 4f98885a124307dcf2adb8a1985b4eccf342f809 Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Mon, 21 Jul 2025 21:12:37 +0300 Subject: [PATCH 3/7] Add send_after --- .../src/drift/js/internal/event_loop.gleam | 4 +++ drift_js/src/drift/js/runtime.gleam | 10 +++++++ drift_js/src/drift_event_loop.mjs | 4 +++ .../drift/js/internal/event_loop_test.gleam | 19 ++++++++++++ drift_js/test/drift_js_test.gleam | 29 ++++++++++++++++++- 5 files changed, 65 insertions(+), 1 deletion(-) diff --git a/drift_js/src/drift/js/internal/event_loop.gleam b/drift_js/src/drift/js/internal/event_loop.gleam index 7ee6fc9..a432ac9 100644 --- a/drift_js/src/drift/js/internal/event_loop.gleam +++ b/drift_js/src/drift/js/internal/event_loop.gleam @@ -44,6 +44,10 @@ pub fn error_if_stopped( @external(javascript, "../../../drift_event_loop.mjs", "send") pub fn send(loop: EventLoop(i), input: i) -> Nil +/// Uses `setTimeout` to call `send` after a delay. +@external(javascript, "../../../drift_event_loop.mjs", "send_after") +pub fn send_after(loop: EventLoop(i), delay: Int, input: i) -> Nil + /// Sets the time to the next time `receive` should return `Tick`. /// Only one timeout can be set at a time. /// Returns an error if a timeout is already set. diff --git a/drift_js/src/drift/js/runtime.gleam b/drift_js/src/drift/js/runtime.gleam index 911a4c6..daf96b4 100644 --- a/drift_js/src/drift/js/runtime.gleam +++ b/drift_js/src/drift/js/runtime.gleam @@ -35,10 +35,19 @@ pub type TerminalResult(a, e) { } /// Sends an input to be handled by the runtime. +/// This will resolve a promise under the hood, and thus completing the receive +/// will be scheduled as a microtask. pub fn send(runtime: Runtime(i), input: i) -> Nil { event_loop.send(runtime.loop, input) } +/// Sends an input to be handled by the runtime after a delay (in milliseconds). +/// Since triggering the receive from `send` will be scheduled as a microtask, +/// using `send_after` with a delay of 0 can be used to +pub fn send_after(runtime: Runtime(i), delay: Int, input: i) -> Nil { + event_loop.send_after(runtime.loop, delay, input) +} + /// Similar to `process.call_forever` on Gleam on Erlang. pub fn call_forever( runtime: Runtime(i), @@ -157,4 +166,5 @@ fn stop( /// Returns a monotonic timestamp in milliseconds. /// The reference point (value 0) is not defined. @external(javascript, "../../drift_event_loop.mjs", "now") +@internal pub fn now() -> Int diff --git a/drift_js/src/drift_event_loop.mjs b/drift_js/src/drift_event_loop.mjs index 20608a9..19a9ba9 100644 --- a/drift_js/src/drift_event_loop.mjs +++ b/drift_js/src/drift_event_loop.mjs @@ -38,6 +38,10 @@ export function send(loop, message) { return loop.send(new HandleInput(message)); } +export function send_after(loop, delay, message) { + setTimeout(() => send(loop, message), delay); +} + export function set_timeout(loop, after) { return loop.setTimeout(after); } diff --git a/drift_js/test/drift/js/internal/event_loop_test.gleam b/drift_js/test/drift/js/internal/event_loop_test.gleam index eacffa8..0638f7c 100644 --- a/drift_js/test/drift/js/internal/event_loop_test.gleam +++ b/drift_js/test/drift/js/internal/event_loop_test.gleam @@ -2,6 +2,7 @@ import drift/js/internal/event_loop.{ type Event, type EventLoop, AlreadyReceiving, AlreadyTicking, HandleInput, Stopped, Tick, } +import drift/js/runtime import gleam/javascript/promise.{type Promise, await} pub fn receive_when_empty_test() { @@ -84,6 +85,24 @@ pub fn send_cancels_timeout_test() { promise.resolve(Nil) } +pub fn send_after_test() { + let loop = event_loop.start() + let start = runtime.now() + + event_loop.send_after(loop, 10, 42) + + let assert Ok(result) = event_loop.receive(loop) + use result <- await(timeout(result, 20)) + assert result == Ok(HandleInput(42)) + + // Assert reasonably accurate delay + let elapsed = runtime.now() - start + assert elapsed >= 10 + assert elapsed <= 12 + + promise.resolve(Nil) +} + pub fn queued_receive_cancels_timeout_test() { let loop = event_loop.start() diff --git a/drift_js/test/drift_js_test.gleam b/drift_js/test/drift_js_test.gleam index 8c81c50..6c68a0d 100644 --- a/drift_js/test/drift_js_test.gleam +++ b/drift_js/test/drift_js_test.gleam @@ -1,9 +1,10 @@ import drift.{type Action, type Context, type Effect, type Step} import drift/js/runtime.{ - type Runtime, type TerminalResult, CallTimedOut, RuntimeStopped, + type Runtime, type TerminalResult, CallTimedOut, RuntimeStopped, Terminated, } import exemplify import gleam/javascript/promise.{type Promise, await} +import gleam/list import gleeunit pub fn main() -> Nil { @@ -112,6 +113,32 @@ pub fn call_timeout_test() { promise.resolve(Nil) } +pub fn send_after_0_is_delayed_test() { + use <- timeout(100) + + let #(result, rt) = + start_without_io([], fn(ctx, state, input) { + let state = [input, ..state] + case list.length(state) { + 4 -> drift.stop(ctx, state) + _ -> drift.continue(ctx, state) + } + }) + + runtime.send_after(rt, 0, "after1") + runtime.send(rt, "immediate1") + runtime.send_after(rt, 0, "after2") + runtime.send(rt, "immediate2") + use result <- promise.await(result) + + // Reverse list for clearer assertion + let assert Terminated(result) = result + let result = list.reverse(result) + assert result == ["immediate1", "immediate2", "after1", "after2"] + + promise.resolve(Nil) +} + fn noop(ctx: Context(i, o), state: s, _: i) -> Step(s, i, o, e) { ctx |> drift.continue(state) } From 746ba4403ce668be61b4490ad8d92bca06e15efd Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Mon, 21 Jul 2025 21:19:09 +0300 Subject: [PATCH 4/7] Finish comment. --- drift_js/src/drift/js/runtime.gleam | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/drift_js/src/drift/js/runtime.gleam b/drift_js/src/drift/js/runtime.gleam index daf96b4..a87d6a1 100644 --- a/drift_js/src/drift/js/runtime.gleam +++ b/drift_js/src/drift/js/runtime.gleam @@ -43,7 +43,8 @@ pub fn send(runtime: Runtime(i), input: i) -> Nil { /// Sends an input to be handled by the runtime after a delay (in milliseconds). /// Since triggering the receive from `send` will be scheduled as a microtask, -/// using `send_after` with a delay of 0 can be used to +/// using `send_after` with a delay of 0 can be used to handle an input as +/// a task instead. pub fn send_after(runtime: Runtime(i), delay: Int, input: i) -> Nil { event_loop.send_after(runtime.loop, delay, input) } From 303102767f69581118a52a1212c4b3e89eae9fe9 Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Wed, 23 Jul 2025 19:37:12 +0300 Subject: [PATCH 5/7] Revert accidental change --- drift_js/src/drift/js/runtime.gleam | 1 - 1 file changed, 1 deletion(-) diff --git a/drift_js/src/drift/js/runtime.gleam b/drift_js/src/drift/js/runtime.gleam index a87d6a1..83eccd7 100644 --- a/drift_js/src/drift/js/runtime.gleam +++ b/drift_js/src/drift/js/runtime.gleam @@ -167,5 +167,4 @@ fn stop( /// Returns a monotonic timestamp in milliseconds. /// The reference point (value 0) is not defined. @external(javascript, "../../drift_event_loop.mjs", "now") -@internal pub fn now() -> Int From 51bf11bb6e297555774146440912fb186e0b8a45 Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Wed, 23 Jul 2025 19:37:57 +0300 Subject: [PATCH 6/7] Use less ambiguous terminology in comment --- drift_js/src/drift/js/channel.gleam | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drift_js/src/drift/js/channel.gleam b/drift_js/src/drift/js/channel.gleam index 081e74d..f44d1b6 100644 --- a/drift_js/src/drift/js/channel.gleam +++ b/drift_js/src/drift/js/channel.gleam @@ -3,7 +3,7 @@ import gleam/javascript/promise.{type Promise} -/// An unbounded single-consumer channel with synchronous sending +/// An unbounded single-consumer channel with non-blocking sending /// and asynchronous receiving. /// Intended to be used similarly to a `gleam/erlang` `Subject`. pub type Channel(a) From 0c7ff418ebc76d2f0e0944bc46012fb26a6cbad8 Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Wed, 23 Jul 2025 19:47:09 +0300 Subject: [PATCH 7/7] Add changelog entry --- drift_js/CHANGELOG | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/drift_js/CHANGELOG b/drift_js/CHANGELOG index b0fa781..6263979 100644 --- a/drift_js/CHANGELOG +++ b/drift_js/CHANGELOG @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +- Adds the `send_after` function to `runtime`, which allows sending messages + with a delay, or scheduling them on the task queue with a delay of zero. + ## [1.0.1] - 2025-17-20 ### Fixed