agents_control 0.3.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7cbdfdaec647d99052be4572b93c02e8a1ba8f1169219a85c3abebb1ce1d794b
4
- data.tar.gz: 5d486d2543a88f451eec31e8261ddf8fafb8170446f09f1f13596b07e639f19a
3
+ metadata.gz: 5b56040135b5403eefcaaa60ef384fcba2f5cb9bf28add127443bfbe741df2ee
4
+ data.tar.gz: c19ff5fd9a1dfd0cb46373b2ec58e66b095108072ca18efbb3342f9a3c34f121
5
5
  SHA512:
6
- metadata.gz: e47eca628d62039dd339691b713ad2b123f5fe9205b0394f623fe51806cbb0711b3ee93f5708548855049d6ebf3631776276e8f21b3b977e776392f7bb842be1
7
- data.tar.gz: cdec6b32aade79e9b165c72ac9fc121116b1a90ab1c7e16066737561653c9e5090ab87b593e55da04ca78dbe02099a4903bc626b008fe16170a448a65ab8ecf3
6
+ metadata.gz: 73cbb9c93c0a7b506ed52e51811c9ed2462276c59a3e10f37747054fc05be8b2b2fed83aec1110a151b4ca65470673b7ec65791d5e2d400cd3199cd448306e75
7
+ data.tar.gz: 6825f34afc030b952b5bc6690170241844888b71e5270fa057597f236a0e2869da1c1caff6cfb4563faee28d84a403d9775fa434d780d672057bc1a1e4dc8ea1
@@ -242,10 +242,16 @@ module AgentsControl
242
242
  raise NetworkError, e.message
243
243
  end
244
244
 
245
+ # SystemCallError as a family rather than a list of Errno
246
+ # constants. The list was a bet on knowing every errno a network
247
+ # stack can raise, and it lost: Errno::EADDRNOTAVAIL — "can't
248
+ # assign requested address", what a VPN reconnecting produces —
249
+ # wasn't on it, so it escaped as itself instead of arriving as
250
+ # Unavailable, and killed the polling thread outright. The
251
+ # daemon stayed up with its port open and simply stopped hearing
252
+ # Telegram, for days, with nothing in the log after the trace.
245
253
  NETWORK_ERRORS = [
246
- Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
247
- Errno::ENETUNREACH, Errno::ETIMEDOUT, Errno::EPIPE,
248
- SocketError, Timeout::Error, OpenSSL::SSL::SSLError, IOError
254
+ SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, IOError
249
255
  ].freeze
250
256
  end
251
257
  end
@@ -16,6 +16,19 @@ module AgentsControl
16
16
  # closed laptop doesn't hammer reconnects all night.
17
17
  BACKOFF = [1, 2, 5, 10, 30, 60].freeze
18
18
 
19
+ # A successful poll leaves a timestamp behind, so something
20
+ # outside this process can tell a bot that is listening from one
21
+ # that stopped hours ago. `polling?` answers that in-process, and
22
+ # doctor runs as a separate command — the daemon's port reports
23
+ # only that it is up, which a deaf daemon is too.
24
+ HEARTBEAT_KEY = "telegram:last_poll"
25
+
26
+ # A healthy poll returns at least every telegram.poll_timeout
27
+ # (30s), and immediately when something arrives. Writing on each
28
+ # one would rewrite the store several times a second under
29
+ # traffic, for a reading nobody needs that finely.
30
+ HEARTBEAT_EVERY = 15
31
+
19
32
  def initialize(api:, router:, store:, config:, logger: $stdout)
20
33
  @api = api
21
34
  @router = router
@@ -59,6 +72,12 @@ module AgentsControl
59
72
 
60
73
  def stop = @stopped&.push(nil)
61
74
 
75
+ # Whether the Telegram side is actually alive. The daemon holds
76
+ # its port and answers hooks whether or not this thread is
77
+ # running, so without asking there is no way to tell a bot that
78
+ # is listening from one that died quietly hours ago.
79
+ def polling? = @poller&.alive? || false
80
+
62
81
  def running? = @running
63
82
 
64
83
  private
@@ -69,6 +88,7 @@ module AgentsControl
69
88
  while @running
70
89
  begin
71
90
  poll_once
91
+ heartbeat
72
92
  failures = 0
73
93
  rescue Api::Conflict => e
74
94
  # Another process is reading updates — looping further is
@@ -86,10 +106,41 @@ module AgentsControl
86
106
  failures += 1
87
107
  log("API error: #{e.message}")
88
108
  pause(backoff(failures))
109
+ rescue StandardError => e
110
+ # A backstop, not a place to add expected errors above it.
111
+ # Whatever it is, the bot going deaf until someone notices
112
+ # is worse than retrying: the daemon keeps its port and its
113
+ # hooks either way, so a dead poller looks exactly like a
114
+ # healthy one from outside.
115
+ failures += 1
116
+ log("unexpected #{e.class}: #{redacted(e)}, retrying in #{backoff(failures)}s")
117
+ pause(backoff(failures))
89
118
  end
90
119
  end
91
120
  end
92
121
 
122
+ # The message may carry the token, so it is only printed once
123
+ # redaction has actually succeeded — and redaction failing must
124
+ # not take the handler down with it, which is how the backstop
125
+ # above managed to kill the very thread it exists to keep alive.
126
+ def redacted(error)
127
+ @api.redact(error.message)
128
+ rescue StandardError
129
+ "(message withheld — could not redact)"
130
+ end
131
+
132
+ # Best-effort by design: a store that can't be written is not a
133
+ # reason to stop reading Telegram, which is the whole job.
134
+ def heartbeat
135
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
136
+ return if @last_heartbeat && now - @last_heartbeat < HEARTBEAT_EVERY
137
+
138
+ @last_heartbeat = now
139
+ @store.put(Time.now.to_i, ttl: 86_400, key: HEARTBEAT_KEY)
140
+ rescue StandardError => e
141
+ log("couldn't record the poll heartbeat: #{e.class}")
142
+ end
143
+
93
144
  def poll_once
94
145
  updates = @api.get_updates(offset: offset, timeout: @config.get("telegram.poll_timeout", 30))
95
146
 
@@ -18,16 +18,17 @@ module AgentsControl
18
18
  # control the app.
19
19
  NOT_AUTHORISED = "-1743"
20
20
 
21
- def initialize(config: nil, secrets: nil, executor: Executor.new, api: nil)
21
+ def initialize(config: nil, secrets: nil, executor: Executor.new, api: nil, store: nil)
22
22
  @config = config || Config.load
23
23
  @secrets = secrets || Secrets.new
24
24
  @executor = executor
25
25
  @api = api
26
+ @store = store
26
27
  end
27
28
 
28
29
  CHECKS = %i[
29
30
  ruby_check terminal_check automation_check agent_binary_check
30
- token_check chats_check passphrase_check bot_check daemon_check hooks_check
31
+ token_check chats_check passphrase_check bot_check daemon_check polling_check hooks_check
31
32
  anchors_check wake_check
32
33
  ].freeze
33
34
 
@@ -185,6 +186,36 @@ module AgentsControl
185
186
  end
186
187
  end
187
188
 
189
+ # The daemon holds its port and answers hooks whether or not it is
190
+ # still reading Telegram, so "running" and "listening" are separate
191
+ # questions and only the first was ever asked. A poll loop that died
192
+ # on a network error looked exactly like a healthy one — for days,
193
+ # in the case that prompted this.
194
+ # Derived from the configured long-poll timeout rather than fixed:
195
+ # a healthy poll returns at least that often, so a hand-raised
196
+ # telegram.poll_timeout would otherwise make a perfectly good daemon
197
+ # read as deaf. Two windows plus a minute leaves room for a retry
198
+ # and the heartbeat's own throttle.
199
+ def stale_after
200
+ [(@config.get("telegram.poll_timeout", 30).to_i * 2) + 60, 120].max
201
+ end
202
+
203
+ def polling_check
204
+ return nil unless daemon_response(@config.get("hooks.port", Daemon::DEFAULT_PORT)) == :ours
205
+
206
+ last = store.get(Channels::Telegram::Bot::HEARTBEAT_KEY)
207
+ return warn("Telegram", "the daemon hasn't reported a poll yet",
208
+ "if it persists, restart: agents_control stop && agents_control") unless last
209
+
210
+ age = Time.now.to_i - last.to_i
211
+ return ok("Telegram", "polling — last heard #{age}s ago") if age < stale_after
212
+
213
+ fail("Telegram", "the daemon is up but hasn't polled for #{age / 60} min — messages aren't arriving",
214
+ "agents_control stop && agents_control")
215
+ end
216
+
217
+ def store = @store ||= Store.new
218
+
188
219
  def daemon_response(port)
189
220
  socket = Socket.tcp("127.0.0.1", port, connect_timeout: 1)
190
221
  socket.print("POST /ping HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\n\r\n{}")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentsControl
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agents_control
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sapar Kurmanov