capybara-lightpanda 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +1 -1
- data/lib/capybara/lightpanda/client/web_socket.rb +45 -14
- data/lib/capybara/lightpanda/client.rb +8 -3
- data/lib/capybara/lightpanda/downloads.rb +2 -2
- data/lib/capybara/lightpanda/driver.rb +7 -0
- data/lib/capybara/lightpanda/javascripts/attach.js +1 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +32 -13
- data/lib/capybara/lightpanda/node.rb +7 -19
- data/lib/capybara/lightpanda/process.rb +16 -4
- data/lib/capybara/lightpanda/utils/wait.rb +10 -0
- data/lib/capybara/lightpanda/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c3e05fa2b4d3b676fc6dfdd88fb5c4c65b7c2d84ae979a3c6b2cc6bcfddbd712
|
|
4
|
+
data.tar.gz: ded427c3ae0148dc351d7b43ade13daf7c4f8aebc1c6492c0480b5a146d116f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a7d53b5fa0e8d971ca16b9d82a96b65c313e1663313775959b226f97d530e8c84ee19d33b4ec085983bb5d659ff262de97aa6d2501b24ffe0df25755589dae9b
|
|
7
|
+
data.tar.gz: c2bf56759b971c045a46a589c0b6bcfa31ee2de84c6f995b62731c973b99dec8b03fb9d10f22c4e4a445673ae3c1659ddfc87dd2ea19a5a32ccc9491097993d6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.17.0] - 2026-11-05
|
|
4
|
+
|
|
5
|
+
> **Update Lightpanda before upgrading.** This release requires Lightpanda **1.0.3** or a nightly build ≥ 11647. The driver refuses anything older and names the version it found. Pin `1.0.3` for a reproducible browser.
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **`<a download>` links download natively on Lightpanda 1.0.3.** The driver used to wait for a 1.1 release before letting the browser handle a link's `download` attribute itself, so on 1.0.3 it also went to the link's URL. Every supported browser now handles it natively, and the driver leaves the download to it.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **`reset_session!` no longer starts a browser just to reset it.** Resetting a session that never opened a page used to launch Lightpanda first. When that launch failed, the reset waited for the full process timeout a second time.
|
|
14
|
+
- **Contenteditable elements are detected as browsers detect them.** `contenteditable="plaintext-only"` now counts as editable. An unrecognised value such as `contenteditable="bogus"` now inherits from its parent instead of making the element editable. A page with `document.designMode = "on"` is editable throughout. SVG elements are ignored. This affects which element a click focuses and where `send_keys` types.
|
|
15
|
+
- **A stalled browser can no longer hang a command past its timeout.** The timeout used to cover only the wait for the reply, so a browser that had stopped reading could hold the call much longer while it was still being sent. Sending and waiting now share one deadline.
|
|
16
|
+
|
|
3
17
|
## [0.16.0] - 2026-10-26
|
|
4
18
|
|
|
5
19
|
### Fixed
|
data/README.md
CHANGED
|
@@ -57,7 +57,7 @@ driven_by :lightpanda
|
|
|
57
57
|
> Lightpanda is a headless agentic browser, not a layout engine. External `<link rel="stylesheet">` **are** fetched and applied (the gem enables this by default), and `@media` / `window.matchMedia()` evaluate against the `window_size` you configure — so a mobile-only CTA gated by `@media (max-width: …)` resolves at the width you ask for. What's missing is *layout*: nothing reflows, `getBoundingClientRect` stays synthetic, and there is no real scroll. Specs that assert on pixel geometry, scrolling, or screenshots can't pass here, nor can the two that catch people out: a second browser tab and a menu revealed purely by CSS `:hover`. See [known limitations](https://navidemad.github.io/capybara-lightpanda/docs/#limits).
|
|
58
58
|
|
|
59
59
|
> [!TIP]
|
|
60
|
-
> For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "1.0.
|
|
60
|
+
> For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "1.0.3"` (the tagged release the gem's current floor is pinned to — nightly build 11647 is that release's commit). Without a pin the driver tracks Lightpanda's rolling `nightly` tag, which moves under you. See [Pinning the browser version](https://navidemad.github.io/capybara-lightpanda/docs/#pinning).
|
|
61
61
|
|
|
62
62
|
## Credits
|
|
63
63
|
|
|
@@ -5,6 +5,7 @@ require "socket"
|
|
|
5
5
|
require "websocket/driver"
|
|
6
6
|
|
|
7
7
|
require_relative "../utils/attempt"
|
|
8
|
+
require_relative "../utils/wait"
|
|
8
9
|
|
|
9
10
|
module Capybara
|
|
10
11
|
module Lightpanda
|
|
@@ -26,17 +27,27 @@ module Capybara
|
|
|
26
27
|
connect
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
#
|
|
30
|
-
# the
|
|
31
|
-
|
|
30
|
+
# The frame must be sent by `deadline` (monotonic seconds; Client#command
|
|
31
|
+
# passes the command's own deadline) or within `timeout` seconds; both
|
|
32
|
+
# nil fall back to options.timeout. A frame still queued behind the
|
|
33
|
+
# socket lock at the deadline wrote nothing, so it raises TimeoutError
|
|
34
|
+
# and the connection stays usable; one cut off mid-write kills the
|
|
35
|
+
# connection (see #write).
|
|
36
|
+
def send_message(message, timeout: nil, deadline: nil)
|
|
32
37
|
raise DeadBrowserError, "WebSocket is not open" unless @status == :open
|
|
33
38
|
|
|
39
|
+
deadline ||= Utils::Wait.deadline(timeout || @options.timeout)
|
|
34
40
|
@logger&.puts("\n\n▶ #{@logger.elapsed_time} #{message}")
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
unless acquire_driver_lock?(deadline)
|
|
42
|
+
raise TimeoutError, "Frame not sent before its deadline: another frame held the socket"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
@write_deadline = deadline
|
|
37
47
|
@driver.text(message)
|
|
38
48
|
ensure
|
|
39
|
-
@
|
|
49
|
+
@write_deadline = nil
|
|
50
|
+
@driver_mutex.unlock
|
|
40
51
|
end
|
|
41
52
|
end
|
|
42
53
|
|
|
@@ -65,11 +76,11 @@ module Capybara
|
|
|
65
76
|
# on Lightpanda's single thread) would block a plain write forever
|
|
66
77
|
# once the frame outgrows the socket buffers, holding the lock that
|
|
67
78
|
# every other command, the reader's parse and #close need — and the
|
|
68
|
-
# command
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
79
|
+
# command would never time out. So the write gives up at the frame's
|
|
80
|
+
# deadline even when the peer is still taking bytes: a trickling peer
|
|
81
|
+
# must not hold the lock past the caller's budget (ferrum #644). Close
|
|
82
|
+
# and pong frames have no command deadline; they give up once the
|
|
83
|
+
# browser takes no byte for options.timeout.
|
|
73
84
|
def write(data)
|
|
74
85
|
until data.empty?
|
|
75
86
|
written = @socket.write_nonblock(data, exception: false)
|
|
@@ -96,7 +107,9 @@ module Capybara
|
|
|
96
107
|
private
|
|
97
108
|
|
|
98
109
|
def wait_for_socket(state)
|
|
99
|
-
timeout = @
|
|
110
|
+
timeout = @write_deadline ? Utils::Wait.remaining(@write_deadline) : @options.timeout
|
|
111
|
+
return false if timeout <= 0
|
|
112
|
+
|
|
100
113
|
state == :wait_readable ? @socket.wait_readable(timeout) : @socket.wait_writable(timeout)
|
|
101
114
|
end
|
|
102
115
|
|
|
@@ -104,10 +117,28 @@ module Capybara
|
|
|
104
117
|
# mark it dead (the reader and message threads stop, pending commands
|
|
105
118
|
# are released) and close the socket.
|
|
106
119
|
def abandon_stalled_write
|
|
107
|
-
|
|
120
|
+
reason = if @write_deadline
|
|
121
|
+
"Browser did not take the whole frame before the command's deadline"
|
|
122
|
+
else
|
|
123
|
+
"Browser accepted no data for #{@options.timeout}s"
|
|
124
|
+
end
|
|
108
125
|
mark_dead(:error)
|
|
109
126
|
close_socket
|
|
110
|
-
raise DeadBrowserError, "
|
|
127
|
+
raise DeadBrowserError, "#{reason} and is likely stuck; connection closed"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Mutex has no timed lock. Poll try_lock with a short capped backoff:
|
|
131
|
+
# the lock is normally held for one frame write or one parse.
|
|
132
|
+
def acquire_driver_lock?(deadline)
|
|
133
|
+
delay = 0.0005
|
|
134
|
+
until @driver_mutex.try_lock
|
|
135
|
+
remaining = Utils::Wait.remaining(deadline)
|
|
136
|
+
return false if remaining <= 0
|
|
137
|
+
|
|
138
|
+
sleep([delay, remaining].min)
|
|
139
|
+
delay = [delay * 2, 0.01].min
|
|
140
|
+
end
|
|
141
|
+
true
|
|
111
142
|
end
|
|
112
143
|
|
|
113
144
|
def close_socket
|
|
@@ -20,21 +20,26 @@ module Capybara
|
|
|
20
20
|
start_message_thread
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# `timeout` bounds the whole command: waiting for the socket lock, writing
|
|
24
|
+
# the frame and waiting for the response all draw on one deadline, so a
|
|
25
|
+
# slow send can't stretch a command past its budget.
|
|
23
26
|
def command(method, params = {}, async: false, session_id: nil, timeout: nil)
|
|
24
27
|
message = build_message(method, params, session_id: session_id)
|
|
25
28
|
effective_timeout = timeout || @options.timeout
|
|
29
|
+
deadline = Utils::Wait.deadline(effective_timeout)
|
|
26
30
|
|
|
27
31
|
if async
|
|
28
|
-
@ws.send_message(JSON.generate(message),
|
|
32
|
+
@ws.send_message(JSON.generate(message), deadline: deadline)
|
|
29
33
|
return true
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
pending = Concurrent::IVar.new
|
|
33
37
|
@pendings[message[:id]] = pending
|
|
34
38
|
|
|
35
|
-
@ws.send_message(JSON.generate(message),
|
|
39
|
+
@ws.send_message(JSON.generate(message), deadline: deadline)
|
|
36
40
|
|
|
37
|
-
|
|
41
|
+
# value!(0) still returns a response that landed during the send.
|
|
42
|
+
response = pending.value!(Utils::Wait.remaining(deadline))
|
|
38
43
|
|
|
39
44
|
if response.nil?
|
|
40
45
|
raise DeadBrowserError, "Browser closed during #{method}" if @ws.closed?
|
|
@@ -13,8 +13,8 @@ module Capybara
|
|
|
13
13
|
# a blocking `wait`, the way ferrum's Downloads does.
|
|
14
14
|
#
|
|
15
15
|
# Navigation downloads require Content-Disposition: attachment, not just a
|
|
16
|
-
# MIME type.
|
|
17
|
-
# 11097
|
|
16
|
+
# MIME type. The floor also honors an anchor's download attribute (upstream
|
|
17
|
+
# #4124, 11097); Node#click lets that native activation finish.
|
|
18
18
|
# Capybara's MIME-only :download shared spec still stays in capybara_skip.
|
|
19
19
|
#
|
|
20
20
|
# Structure deliberately mirrors Network (same browser ref, same mutex +
|
|
@@ -322,7 +322,14 @@ module Capybara
|
|
|
322
322
|
# turns programmer errors (e.g. a NoMethodError in browser.rb) into a
|
|
323
323
|
# silent quit-and-respawn on every example with zero signal. The warn
|
|
324
324
|
# keeps repeated respawns visible.
|
|
325
|
+
#
|
|
326
|
+
# With no browser (never visited, or the launch itself failed) there is
|
|
327
|
+
# nothing to reset: `browser` would spawn Lightpanda just to reset it,
|
|
328
|
+
# and a failed launch would cost a second process timeout in teardown.
|
|
329
|
+
# The next visit launches as usual.
|
|
325
330
|
def reset!
|
|
331
|
+
return unless @browser
|
|
332
|
+
|
|
326
333
|
browser.reset
|
|
327
334
|
rescue Error, SystemCallError, IOError => e
|
|
328
335
|
warn "[capybara-lightpanda] reset! failed (#{e.class}: #{e.message}); respawning browser"
|
|
@@ -92,19 +92,32 @@ function isDisabled(el) {
|
|
|
92
92
|
return false;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
95
|
+
// The element's own contenteditable state: true for the "", "true" and
|
|
96
|
+
// "plaintext-only" values, false for "false", null for inherit (no attribute,
|
|
97
|
+
// an invalid value, or a non-HTML element — the attribute is HTML-only).
|
|
98
|
+
// Mirrors upstream Element.contentEditableState.
|
|
99
|
+
function contentEditableState(el) {
|
|
100
|
+
if (el.namespaceURI !== 'http://www.w3.org/1999/xhtml') return null;
|
|
101
|
+
if (!el.hasAttribute || !el.hasAttribute('contenteditable')) return null;
|
|
102
|
+
var v = (el.getAttribute('contenteditable') || '').toLowerCase();
|
|
103
|
+
if (v === '' || v === 'true' || v === 'plaintext-only') return true;
|
|
104
|
+
if (v === 'false') return false;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// True if the element can be edited: the nearest contenteditable attribute in
|
|
109
|
+
// a true or false state decides, then the document's designMode. The native
|
|
110
|
+
// `isContentEditable` agrees from upstream #4210 (11229, in the floor; checked
|
|
111
|
+
// on 1.0.3), but SVG elements have no such getter and pointerFocusTarget needs
|
|
112
|
+
// each element's own state, so the walk stays.
|
|
100
113
|
function isContentEditable(el) {
|
|
101
|
-
var n = el;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
for (var n = el; n; n = n.parentNode) {
|
|
115
|
+
if (n.nodeType === 1) {
|
|
116
|
+
var state = contentEditableState(n);
|
|
117
|
+
if (state !== null) return state;
|
|
118
|
+
} else if (n.nodeType === 9) {
|
|
119
|
+
return String(n.designMode).toLowerCase() === 'on';
|
|
106
120
|
}
|
|
107
|
-
n = n.parentElement;
|
|
108
121
|
}
|
|
109
122
|
return false;
|
|
110
123
|
}
|
|
@@ -116,9 +129,15 @@ function isContentEditable(el) {
|
|
|
116
129
|
// only kind CLICK_JS can dispatch — never runs the focus default, so the
|
|
117
130
|
// driver has to apply it itself or `click` then `page.send_keys` types nowhere.
|
|
118
131
|
function pointerFocusTarget(el) {
|
|
132
|
+
// A contenteditable attribute in the true state, or the body of a
|
|
133
|
+
// designMode document (Chrome edits and focuses the body, not <html>).
|
|
119
134
|
function isEditingHost(n) {
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
var state = contentEditableState(n);
|
|
136
|
+
if (state !== null) return state;
|
|
137
|
+
var root = n.parentNode, doc = root && root.parentNode;
|
|
138
|
+
return (n.tagName || '').toUpperCase() === 'BODY' &&
|
|
139
|
+
(root.tagName || '').toUpperCase() === 'HTML' &&
|
|
140
|
+
!!doc && doc.nodeType === 9 && String(doc.designMode).toLowerCase() === 'on';
|
|
122
141
|
}
|
|
123
142
|
function isFocusable(n) {
|
|
124
143
|
if (isDisabled(n)) return false;
|
|
@@ -135,7 +135,7 @@ module Capybara
|
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
def click(_keys = [], **_options)
|
|
138
|
-
call(CLICK_JS
|
|
138
|
+
call(CLICK_JS)
|
|
139
139
|
driver.browser.wait_for_idle
|
|
140
140
|
end
|
|
141
141
|
|
|
@@ -376,18 +376,6 @@ module Capybara
|
|
|
376
376
|
|
|
377
377
|
private
|
|
378
378
|
|
|
379
|
-
# Below #4124 a download attribute suppresses native activation entirely,
|
|
380
|
-
# so the manual navigation is still needed for attachment responses.
|
|
381
|
-
# The API has no feature marker: keep the floor path through 1.0.x and
|
|
382
|
-
# enable native download activation on build 11097 / the 1.1 release line.
|
|
383
|
-
def native_anchor_downloads?
|
|
384
|
-
browser = driver.browser
|
|
385
|
-
build = browser.nightly_build
|
|
386
|
-
return build >= Gem::Version.new("11097") if build
|
|
387
|
-
|
|
388
|
-
browser.release && browser.release >= Gem::Version.new("1.1.0")
|
|
389
|
-
end
|
|
390
|
-
|
|
391
379
|
def find_hinted(method, selector, hints)
|
|
392
380
|
driver.browser.find_within_hinted(@remote_object_id, method, selector, self.class.hint_spec(**hints))
|
|
393
381
|
.map { |oid, cache| self.class.new(driver, oid, cache, frame_scoped: @frame_scoped) }
|
|
@@ -607,7 +595,7 @@ module Capybara
|
|
|
607
595
|
# 6167 as duplicate `turbo:submit-start` events; the first request
|
|
608
596
|
# gets aborted by the second and Turbo never renders the response.
|
|
609
597
|
CLICK_JS = <<~JS
|
|
610
|
-
function(
|
|
598
|
+
function() {
|
|
611
599
|
var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
|
|
612
600
|
// Emulate coordinate hit-testing without a layout engine. In a real
|
|
613
601
|
// browser a pointer click on a container lands on its frontmost
|
|
@@ -672,11 +660,11 @@ module Capybara
|
|
|
672
660
|
var external = !!scheme && !/^(https?|about|blob|data|file|javascript)$/i.test(scheme[1]);
|
|
673
661
|
var notCancelled = hit.dispatchEvent(clickEvt);
|
|
674
662
|
if (external || !notCancelled || clickEvt.defaultPrevented) return;
|
|
675
|
-
// Native activation has already started the download
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
if (
|
|
663
|
+
// Native activation has already started the download (upstream
|
|
664
|
+
// #4124, 11097, in the floor). Navigating again cancels it and
|
|
665
|
+
// replaces the document. Keep the complete synthetic event sequence
|
|
666
|
+
// and its cancellation, but stop here.
|
|
667
|
+
if (link && link.hasAttribute('download')) return;
|
|
680
668
|
var tag = hit.tagName;
|
|
681
669
|
// A `javascript:` link never navigates: the click's own activation
|
|
682
670
|
// behavior has already run its code. Assigning it to location.href
|
|
@@ -302,7 +302,19 @@ module Capybara
|
|
|
302
302
|
# #3658 (10657) client rects follow the scroll and
|
|
303
303
|
# DOM.scrollIntoViewIfNeeded is real — HTML5_DRAG_JS centers the
|
|
304
304
|
# drop target itself because scrollIntoView ignores `block`.
|
|
305
|
-
|
|
305
|
+
#
|
|
306
|
+
# 2026-11-05 bump 10965 -> 11647 (= release 1.0.3, the new lockstep cap;
|
|
307
|
+
# the bad-port block #4430 at 11651, CORS exposed headers #4242 at 11663
|
|
308
|
+
# and the x86_64 macOS CPU baseline #4450 at 11672 stay above it).
|
|
309
|
+
# Retired or ungated by it:
|
|
310
|
+
# #4124 (11097) native anchor downloads — Node#click no longer asks a
|
|
311
|
+
# version gate before skipping its own navigation for a download
|
|
312
|
+
# link; that gate wanted release 1.1.0 although 1.0.3 carries it.
|
|
313
|
+
# Rides along: CSS nesting #3962 (10976), blank-page timers #4148
|
|
314
|
+
# (11012), ShadowRoot hit testing #4184 (11071), same-document
|
|
315
|
+
# navigateToHistoryEntry #3903 (11113), native isContentEditable #4210
|
|
316
|
+
# (11229), contenteditable Backspace/Delete/Ctrl+A #4415 (11610).
|
|
317
|
+
MINIMUM_NIGHTLY_BUILD = Gem::Version.new("11647")
|
|
306
318
|
|
|
307
319
|
# Lines of browser output #output keeps (ferrum keeps the same 100).
|
|
308
320
|
OUTPUT_LINES = 100
|
|
@@ -314,16 +326,16 @@ module Capybara
|
|
|
314
326
|
# bare "0.3.6" carrying no commit counter at all, and MINIMUM_NIGHTLY_BUILD
|
|
315
327
|
# has nothing to compare against. Releases are cut from the same trunk, so
|
|
316
328
|
# a release is acceptable exactly when its own commit count clears the
|
|
317
|
-
# nightly floor. 1.0.
|
|
329
|
+
# nightly floor. 1.0.3 (2026-11-05) is build 11647 — exactly the nightly
|
|
318
330
|
# floor, which is no accident: the floor was raised *to* the release so
|
|
319
|
-
# both channels gate the same commit. (1.0.
|
|
331
|
+
# both channels gate the same commit. (1.0.2 = 10965 predates fixes the
|
|
320
332
|
# floor exists for.)
|
|
321
333
|
#
|
|
322
334
|
# INVARIANT: every MINIMUM_NIGHTLY_BUILD bump must also move this to the
|
|
323
335
|
# first release containing that build (`git rev-list --count <tag>` in the
|
|
324
336
|
# browser repo tells you). Leaving it behind would let the release channel
|
|
325
337
|
# silently accept a binary the nightly channel rejects.
|
|
326
|
-
MINIMUM_RELEASE = Gem::Version.new("1.0.
|
|
338
|
+
MINIMUM_RELEASE = Gem::Version.new("1.0.3")
|
|
327
339
|
|
|
328
340
|
class << self
|
|
329
341
|
# `lightpanda version` prints one of two shapes, and the gem supports
|
|
@@ -42,6 +42,16 @@ module Capybara
|
|
|
42
42
|
def self.monotonic_time
|
|
43
43
|
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
44
44
|
end
|
|
45
|
+
|
|
46
|
+
# A point `seconds` from now on the monotonic clock.
|
|
47
|
+
def self.deadline(seconds)
|
|
48
|
+
monotonic_time + seconds
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Seconds left before `deadline`, never negative.
|
|
52
|
+
def self.remaining(deadline)
|
|
53
|
+
[deadline - monotonic_time, 0].max
|
|
54
|
+
end
|
|
45
55
|
end
|
|
46
56
|
end
|
|
47
57
|
end
|