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

Accurate Polling #7

Merged
merged 1 commit into from
Jun 22, 2024
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## [2.2.3] Unreleased

- Implement more accurate polling
- Make `poll` function part of the public `chrobot` API

## [2.2.3] 2024-06-08

- Add `launch_window` function to launch browser in headful mode
Expand Down
2 changes: 1 addition & 1 deletion gleam.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name = "chrobot"
version = "2.2.3"
version = "2.2.4"

description = "A browser automation tool and interface to the Chrome DevTools Protocol."
licences = ["MIT"]
Expand Down
62 changes: 48 additions & 14 deletions src/chrobot.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import gleam/erlang/process.{type Subject}
import gleam/json
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/otp/task
import gleam/result
import gleam/string
import protocol
Expand Down Expand Up @@ -762,21 +763,54 @@ pub fn defer_quit(browser: Subject(chrome.Message), body) {
}

// ---- UTILS
const poll_interval = 100

// TODO measure time elapsed during function call and take it into account
/// Periodically try to call the function until it returns a
/// result instead of an error.
/// Note: doesn't handle elapsed time during function call attempt yet
fn poll(callback: fn() -> Result(a, b), remaining_time: Int) -> Result(a, b) {
case callback() {
Ok(a) -> Ok(a)
Error(b) if remaining_time <= 0 -> {
Error(b)
const poll_delay = 5

/// Utility to repeatedly call a browser function until it succeeds or times out.
pub fn poll(
callback: fn() -> Result(a, chrome.RequestError),
timeout: Int,
) -> Result(a, chrome.RequestError) {
let deadline = utils.get_time_ms() + timeout
do_poll(callback, deadline, None)
}

fn do_poll(
callback: fn() -> Result(a, chrome.RequestError),
deadline: Int,
previous_error: Option(chrome.RequestError),
) -> Result(a, chrome.RequestError) {
// available time before current polling attempt
let available_time = deadline - utils.get_time_ms()

let result =
callback
|> task.async()
|> task.try_await(available_time)

// remaining available time after the polling attempt finishes
let available_time = deadline - utils.get_time_ms() - poll_delay

case result {
// Task did not return before deadline
// A task exit should never happen but we consider it a timeout
Error(task.Timeout) | Error(task.Exit(_)) -> {
// We return the error from the last failed poll attempt if there was one
case previous_error {
Some(err) -> Error(err)
None -> Error(chrome.ChromeAgentTimeout)
}
}
// Task returned Ok result, polling is done
// and result is returned
Ok(Ok(res)) -> Ok(res)
// Task returned an error and we still have time, we continue polling with delay
Ok(Error(err)) if available_time > 0 -> {
process.sleep(poll_delay)
do_poll(callback, deadline, Some(err))
}
Error(_) -> {
process.sleep(poll_interval)
poll(callback, remaining_time - poll_interval)
// Task returned an error but the time is up
Ok(Error(err)) -> {
Error(err)
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/chrobot/internal/utils.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,6 @@ pub fn try_call_with_subject(
Ok(res) -> res
}
}

@external(erlang, "chrobot_ffi", "get_time_ms")
pub fn get_time_ms() -> Int
14 changes: 13 additions & 1 deletion src/chrobot_ffi.erl
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
-module(chrobot_ffi).
-include_lib("kernel/include/file.hrl").
-export([open_browser_port/2, send_to_port/2, get_arch/0, unzip/2, set_executable/1, run_command/1]).
-export([open_browser_port/2, send_to_port/2, get_arch/0, unzip/2, set_executable/1, run_command/1, get_time_ms/0]).

% ---------------------------------------------------
% RUNTIME
% ---------------------------------------------------

% FFI to interact with the browser via a port from erlang
% since gleam does not really support ports yet.
% module: chrome.gleam

% The port is opened with the option "nouse_stdio"
% which makes it use file descriptors 3 and 4 for stdin and stdout
Expand Down Expand Up @@ -37,6 +38,7 @@ send_to_port(Port, BinaryString) ->
% ---------------------------------------------------

% Utils for the installer script
% module: browser_install.gleam

% Get the architecture of the system
get_arch() ->
Expand Down Expand Up @@ -75,3 +77,13 @@ set_executable(FilePath) ->
{error, Reason} ->
{error, Reason}
end.

% ---------------------------------------------------
% UTILITIES
% ---------------------------------------------------

% Miscelaneous utilities
% module: chrobot/internal/utils.gleam

get_time_ms() ->
os:system_time(millisecond).
75 changes: 75 additions & 0 deletions test/chrobot_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import chrobot
import chrobot/internal/utils
import chrome
import gleam/dynamic
import gleam/erlang/process
import gleam/io
import gleam/list
import gleam/result
Expand Down Expand Up @@ -297,3 +298,77 @@ pub fn press_key_test() {
|> should.be_ok
|> should.equal("ENTER KEY PRESSED")
}

pub fn poll_test() {
let initial_time = utils.get_time_ms()

// this function will start returning "Success" in 200ms
let poll_function = fn() {
case utils.get_time_ms() - initial_time {
time if time > 200 -> Ok("Success")
_ -> Error(chrome.NotFoundError)
}
}

chrobot.poll(poll_function, 500)
|> should.be_ok()
|> should.equal("Success")
}

pub fn poll_failure_test() {
let initial_time = utils.get_time_ms()

// this function will start returning "Success" in 200ms
let poll_function = fn() {
case utils.get_time_ms() - initial_time {
time if time > 200 -> Ok("Success")
_ -> Error(chrome.NotFoundError)
}
}

case chrobot.poll(poll_function, 100) {
Error(chrome.NotFoundError) -> {
should.be_true(True)
let elapsed_time = utils.get_time_ms() - initial_time
// timeout should be within a 10ms window of accuracy
{ elapsed_time < 105 && elapsed_time > 95 }
|> should.be_true()
}
_ -> {
utils.err("Polling function didn't return the correct error")
should.fail()
}
}
}

pub fn poll_timeout_failure_test() {
let initial_time = utils.get_time_ms()

// this function will return errors first
// and after 100ms it will start blocking for 10s
let poll_function = fn() {
case utils.get_time_ms() - initial_time {
time if time > 100 -> {
process.sleep(10_000)
Ok("Success")
}
_ -> Error(chrome.NotFoundError)
}
}

// the timeout is 300ms, so the polling function will be interrupted
// while it's blocking, it should still return the original error
case chrobot.poll(poll_function, 300) {
Error(chrome.NotFoundError) -> {
should.be_true(True)
let elapsed_time = utils.get_time_ms() - initial_time
// timeout should be within a 10ms window of accuracy
{ elapsed_time < 305 && elapsed_time > 295 }
|> should.be_true()
}
_ -> {
utils.err("Polling function didn't return the correct error")
should.fail()
}
}
}