hibiki_rails 0.20.0 → 0.20.1
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 +22 -0
- data/app/assets/javascripts/hibiki.js +16 -10
- data/lib/hibiki/rails/action_order.rb +96 -0
- data/lib/hibiki/rails/channel.rb +29 -10
- data/lib/hibiki/rails/version.rb +1 -1
- data/lib/hibiki/rails.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fcb75b30dd2f92f65a4581f6b26ca93f92ef7665c333139b3b6b1f839db70943
|
|
4
|
+
data.tar.gz: 4de446211377d8a95301b0fc5cd9d5361d6ce76489da9bb224711f90a29d51a2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b4c546401703167cc3d2f6ed5690a0b93d95f631fd311d3cc1883a51a6b29365a9a631251690fc351708fba80af4e73fd303034c92f5f7192c6148da26aea644
|
|
7
|
+
data.tar.gz: 3657421c216fcc9f44c2a7d6d7a14a44e0737d9029e37f4e2a0652c31b4b486aba25f4888c045df32d2c88ba1f55f0de4b18cdaf43df29dd68b1fb89cb8b2887
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ The gem and the npm package are released in lockstep and share these version
|
|
|
4
4
|
numbers — `app/assets/javascripts/hibiki.js` is a single copy served both ways,
|
|
5
5
|
so importmap and bundler apps always resolve identical client code.
|
|
6
6
|
|
|
7
|
+
## 0.20.1 — 2026-10-12
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
**A channel runs its actions in the order the browser sent them.** Action
|
|
12
|
+
Cable hands each incoming message to a worker pool, so two actions sent back
|
|
13
|
+
to back could run swapped. The client sends such a pair whenever a visitor
|
|
14
|
+
types in a debounced field and then clicks or presses Enter at once: the
|
|
15
|
+
typed value and the action leave in the same instant. When they swapped, the
|
|
16
|
+
action ran before the value arrived, so a save could miss the last thing
|
|
17
|
+
typed or be refused.
|
|
18
|
+
|
|
19
|
+
- The channel now runs each subscription's actions by the number the client
|
|
20
|
+
gives them. An action that arrives ahead of its turn waits for the ones
|
|
21
|
+
before it, for at most 100 ms.
|
|
22
|
+
- Update the gem and the npm package together. A client older than 0.20.1
|
|
23
|
+
still works with the new gem, but its first action after a reconnect waits
|
|
24
|
+
100 ms.
|
|
25
|
+
- Nothing changes in your channels or views.
|
|
26
|
+
- An unsubscribe is not numbered, and can still overtake an action sent just
|
|
27
|
+
before the page is left.
|
|
28
|
+
|
|
7
29
|
## 0.20.0 — 2026-10-12
|
|
8
30
|
|
|
9
31
|
### Added
|
|
@@ -422,10 +422,19 @@ export class ChannelController extends Controller {
|
|
|
422
422
|
// undefined when the payload went nowhere — the socket turned out to
|
|
423
423
|
// be closed, or the island was already offline — and the caller owns
|
|
424
424
|
// recovery.
|
|
425
|
+
//
|
|
426
|
+
// The server runs a subscription's actions in seq order (Action Cable's
|
|
427
|
+
// worker pool can swap two sent back to back), so the numbers it sees
|
|
428
|
+
// must run from 1 with none missing: an action dropped in the offline gap
|
|
429
|
+
// takes no number, and linkClosed starts the count again for the fresh
|
|
430
|
+
// subscription a reconnect makes.
|
|
425
431
|
perform(action, payload = {}) {
|
|
432
|
+
delete payload.hbt
|
|
433
|
+
// The offline gap: dropped, and the return says so — a truthy seq here
|
|
434
|
+
// would tell a public caller a repaint is coming when none is.
|
|
435
|
+
if (!this.subscribed && this.connectedOnce) return undefined
|
|
426
436
|
const seq = ++this.seq
|
|
427
437
|
payload.hbk = seq
|
|
428
|
-
delete payload.hbt
|
|
429
438
|
if (this.subscribed) {
|
|
430
439
|
this.sendTrips(payload)
|
|
431
440
|
this.beginBusy(seq, action)
|
|
@@ -453,15 +462,10 @@ export class ChannelController extends Controller {
|
|
|
453
462
|
// the socket dropped, and the island is stamped `offline` for the
|
|
454
463
|
// whole gap — the signal the connect window cannot give, where the
|
|
455
464
|
// page is painted and looks live.
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
return seq
|
|
461
|
-
}
|
|
462
|
-
// The offline gap: dropped, and the return says so — a truthy seq here
|
|
463
|
-
// would tell a public caller a repaint is coming when none is.
|
|
464
|
-
return undefined
|
|
465
|
+
this.sendTrips(payload)
|
|
466
|
+
this.beginBusy(seq, action)
|
|
467
|
+
this.queued.push([action, payload])
|
|
468
|
+
return seq
|
|
465
469
|
}
|
|
466
470
|
|
|
467
471
|
// ActionCable's `connected`, i.e. the server confirmed the subscription.
|
|
@@ -484,6 +488,8 @@ export class ChannelController extends Controller {
|
|
|
484
488
|
// reason — their acks are never coming.
|
|
485
489
|
this.queued = []
|
|
486
490
|
this.settleAll()
|
|
491
|
+
// The fresh subscription expects its actions numbered from 1.
|
|
492
|
+
this.seq = 0
|
|
487
493
|
}
|
|
488
494
|
|
|
489
495
|
setState(state) {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent/scheduled_task"
|
|
4
|
+
|
|
5
|
+
module Hibiki
|
|
6
|
+
module Rails
|
|
7
|
+
# Puts one subscription's incoming actions back in the order the client
|
|
8
|
+
# sent them. ActionCable hands every websocket message to a worker pool
|
|
9
|
+
# (Connection#receive is send_async), so two actions sent back to back
|
|
10
|
+
# can reach Channel#perform_action in either order — and the client's
|
|
11
|
+
# flush sends a typed field and the click after it in one tick.
|
|
12
|
+
#
|
|
13
|
+
# The client numbers its actions under `hbk`, from 1 per subscription
|
|
14
|
+
# with no gaps. #submit calls each job when its number comes up: one
|
|
15
|
+
# ahead of its turn is held until the missing ones arrive, or until
|
|
16
|
+
# GRACE runs out for that gap (a client older than this numbers on
|
|
17
|
+
# across a reconnect, so its first action there waits one grace). A
|
|
18
|
+
# number whose turn has passed, a repeat, and anything that is not an
|
|
19
|
+
# Integer run at once.
|
|
20
|
+
#
|
|
21
|
+
# Jobs run under the lock, on whichever thread releases them, so they
|
|
22
|
+
# must only enqueue: the order of the calls is the order of the queue.
|
|
23
|
+
# Like Registry, this is glue touched from many cable threads, not the
|
|
24
|
+
# graph; a lock is the right tool here.
|
|
25
|
+
class ActionOrder
|
|
26
|
+
# Seconds one gap is waited for. A websocket does not lose messages,
|
|
27
|
+
# so only a number that was never sent waits it out.
|
|
28
|
+
GRACE = 0.1
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@lock = Mutex.new
|
|
32
|
+
@due = 1
|
|
33
|
+
@held = {}
|
|
34
|
+
@closed = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def submit(seq, &job)
|
|
38
|
+
return job.call unless seq.is_a?(Integer)
|
|
39
|
+
|
|
40
|
+
@lock.synchronize do
|
|
41
|
+
next job.call if @closed || seq < @due || @held.key?(seq)
|
|
42
|
+
|
|
43
|
+
@held[seq] = job
|
|
44
|
+
release
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Run what is held, in order, and let everything after straight
|
|
49
|
+
# through. Channel#unsubscribed calls it ahead of the dispose.
|
|
50
|
+
def close
|
|
51
|
+
@lock.synchronize do
|
|
52
|
+
@closed = true
|
|
53
|
+
disarm
|
|
54
|
+
@held.sort.each { |_, job| job.call }
|
|
55
|
+
@held.clear
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def release
|
|
62
|
+
while (job = @held.delete(@due))
|
|
63
|
+
@due += 1
|
|
64
|
+
job.call
|
|
65
|
+
end
|
|
66
|
+
watch
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# One timer per gap: more arrivals behind the same gap do not restart
|
|
70
|
+
# it. `@waiting` is the number waited for, which is also what tells a
|
|
71
|
+
# timer that fires late it is stale.
|
|
72
|
+
def watch
|
|
73
|
+
return disarm if @held.empty?
|
|
74
|
+
return if @waiting == @due
|
|
75
|
+
|
|
76
|
+
disarm
|
|
77
|
+
waiting = @waiting = @due
|
|
78
|
+
@timer = Concurrent::ScheduledTask.execute(GRACE) { give_up(waiting) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def disarm
|
|
82
|
+
@timer&.cancel
|
|
83
|
+
@timer = @waiting = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def give_up(waiting)
|
|
87
|
+
@lock.synchronize do
|
|
88
|
+
next unless @waiting == waiting
|
|
89
|
+
|
|
90
|
+
@due = @held.keys.min
|
|
91
|
+
release
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/hibiki/rails/channel.rb
CHANGED
|
@@ -93,14 +93,17 @@ module Hibiki
|
|
|
93
93
|
# `hbt` is the third reserved key: the trips the browser timed since
|
|
94
94
|
# its last action (see Instrumentation#trips_from). Deleted whether or
|
|
95
95
|
# not anyone listens, for the same reason as `hbk`.
|
|
96
|
+
#
|
|
97
|
+
# ActionCable runs this on its worker pool, several at once and in no
|
|
98
|
+
# order, so the post waits for the seq's turn (see ActionOrder). An
|
|
99
|
+
# action with no seq takes no turn. `asked` is read on arrival: a held
|
|
100
|
+
# action's wait is part of its `action` event.
|
|
96
101
|
def perform_action(data)
|
|
97
102
|
seq = data.delete("hbk")
|
|
98
103
|
trips = trips_from(data.delete("hbt"))
|
|
99
104
|
asked = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
100
105
|
|
|
101
|
-
|
|
102
|
-
# down; drop the action rather than blow up the cable thread.
|
|
103
|
-
posted = @__hibiki_actor&.post do
|
|
106
|
+
post_in_turn(seq) do
|
|
104
107
|
trips.each { instrument("trip", it) }
|
|
105
108
|
instrument_action(data, asked) { Hibiki.batch { super(data) } }
|
|
106
109
|
ensure
|
|
@@ -111,13 +114,6 @@ module Hibiki
|
|
|
111
114
|
# in exactly the case the user most needs it to stop.
|
|
112
115
|
transmit({ ack: seq }) if seq
|
|
113
116
|
end
|
|
114
|
-
|
|
115
|
-
# post yields nil (no actor) or false (queue closed): the block
|
|
116
|
-
# never ran, so neither did its ensure. `dropped` is also what lets
|
|
117
|
-
# the client tell "late" from "never" — nothing is coming for this
|
|
118
|
-
# seq, so it settles immediately rather than waiting out the grace
|
|
119
|
-
# window.
|
|
120
|
-
transmit({ ack: seq, dropped: true }) if seq && !posted
|
|
121
117
|
end
|
|
122
118
|
|
|
123
119
|
private
|
|
@@ -126,6 +122,7 @@ module Hibiki
|
|
|
126
122
|
super
|
|
127
123
|
return reject if cid.blank?
|
|
128
124
|
|
|
125
|
+
@__hibiki_order = ActionOrder.new
|
|
129
126
|
@__hibiki_actor = build_graph_actor
|
|
130
127
|
# The block runs owned + untracked (Hibiki.root); the effects it
|
|
131
128
|
# creates do their dependency-collecting first run right here, on
|
|
@@ -144,10 +141,32 @@ module Hibiki
|
|
|
144
141
|
return unless @__hibiki_actor
|
|
145
142
|
|
|
146
143
|
Hibiki::Rails.registry.unregister(self)
|
|
144
|
+
# Held actions run first: the dispose must be the last job posted.
|
|
145
|
+
@__hibiki_order.close
|
|
147
146
|
@__hibiki_actor.post { instrument("unsubscribe") { @__hibiki_root.dispose } }
|
|
148
147
|
@__hibiki_actor.stop
|
|
149
148
|
end
|
|
150
149
|
|
|
150
|
+
# Post the job to the graph when the action numbered `seq` is due. A
|
|
151
|
+
# rejected subscription has no order and nothing to keep in order.
|
|
152
|
+
#
|
|
153
|
+
# A nil actor means the subscription was rejected or already torn
|
|
154
|
+
# down; drop the action rather than blow up the cable thread. post
|
|
155
|
+
# yields nil (no actor) or false (queue closed): the job never ran, so
|
|
156
|
+
# neither did its ensure. `dropped` is also what lets the client tell
|
|
157
|
+
# "late" from "never" — nothing is coming for this seq, so it settles
|
|
158
|
+
# immediately rather than waiting out the grace window.
|
|
159
|
+
def post_in_turn(seq, &job)
|
|
160
|
+
return post_or_drop(seq, job) unless @__hibiki_order
|
|
161
|
+
|
|
162
|
+
@__hibiki_order.submit(seq) { post_or_drop(seq, job) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def post_or_drop(seq, job)
|
|
166
|
+
posted = @__hibiki_actor&.post(&job)
|
|
167
|
+
transmit({ ack: seq, dropped: true }) if seq && !posted
|
|
168
|
+
end
|
|
169
|
+
|
|
151
170
|
# Build the signal graph: create states/deriveds/effects as instance
|
|
152
171
|
# variables so actions can reach them. Runs once, on the graph's
|
|
153
172
|
# thread, inside Hibiki.root.
|
data/lib/hibiki/rails/version.rb
CHANGED
data/lib/hibiki/rails.rb
CHANGED
|
@@ -67,6 +67,7 @@ end
|
|
|
67
67
|
require_relative "rails/version"
|
|
68
68
|
require_relative "rails/registry"
|
|
69
69
|
require_relative "rails/graph_actor"
|
|
70
|
+
require_relative "rails/action_order"
|
|
70
71
|
require_relative "rails/debounce"
|
|
71
72
|
require_relative "rails/instrumentation"
|
|
72
73
|
require_relative "rails/broadcasts"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hibiki_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.20.
|
|
4
|
+
version: 0.20.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- planetaska
|
|
@@ -181,6 +181,7 @@ files:
|
|
|
181
181
|
- lib/generators/hibiki/rails/upload_field_helpers.rb
|
|
182
182
|
- lib/generators/hibiki/rails/upload_field_injections.rb
|
|
183
183
|
- lib/hibiki/rails.rb
|
|
184
|
+
- lib/hibiki/rails/action_order.rb
|
|
184
185
|
- lib/hibiki/rails/broadcasts.rb
|
|
185
186
|
- lib/hibiki/rails/channel.rb
|
|
186
187
|
- lib/hibiki/rails/commands.rb
|