samagotchi 0.123.0 → 0.124.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.
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "rack"
5
5
  require "rackup/handler/webrick"
6
+ require "rbconfig"
6
7
  require "uri"
7
8
  require "webrick"
8
9
 
@@ -21,9 +22,12 @@ require_relative "update_check"
21
22
  require_relative "token"
22
23
  require_relative "tunnel"
23
24
  require_relative "../config"
25
+ require_relative "../child_env"
26
+ require_relative "../installed_gem"
24
27
  require_relative "../paths"
25
28
  require_relative "../project_scope"
26
29
  require_relative "../log"
30
+ require_relative "../session_manager"
27
31
  require_relative "../version"
28
32
  require_relative "../cli/exit"
29
33
 
@@ -65,6 +69,8 @@ module Samagotchi
65
69
  DEFAULT_HOST = "127.0.0.1"
66
70
  # How long `chi web` waits for a server already on its port to answer.
67
71
  PROBE_TIMEOUT = 0.3
72
+ RESTARTED_ENV = "SAMAGOTCHI_WEB_RESTARTED"
73
+ ADOPT_PIDS_ENV = "SAMAGOTCHI_WEB_ADOPT_PIDS"
68
74
 
69
75
  # The raw header names next to Rack's env (ProxyGate's identity check):
70
76
  # chi web is the only WEBrick server chi runs.
@@ -87,7 +93,9 @@ module Samagotchi
87
93
  def self.launch(port: nil, host: nil, scope: "project", start_dir: nil, open_browser: false, markdown: false,
88
94
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
89
95
  editor: Config::BY_KEY["web.editor"].default, new_token: false,
90
- working_set: Config::BY_KEY["web.working_set"].default, tunnel: nil, start_dir_from: :flag)
96
+ working_set: Config::BY_KEY["web.working_set"].default, tunnel: nil, start_dir_from: :flag,
97
+ restarted: false, restart_argv: ["web"], exec: nil)
98
+ handed_on = false # a :restart leaves Serve to the next process, a run turns it off itself
91
99
  start_given = !start_dir.to_s.strip.empty?
92
100
  start_dir = StartDir.resolve(start_dir, from: start_dir_from)
93
101
  rotate_token if new_token
@@ -120,6 +128,8 @@ module Samagotchi
120
128
  warn in_use_message(port)
121
129
  1
122
130
  else
131
+ provider = nil
132
+ plan = nil
123
133
  if tunnel
124
134
  provider = Tunnel.for(tunnel)
125
135
  plan = provider.prepare(port: port, config_owner: Samagotchi::Config.get("web.proxy.owner"),
@@ -142,11 +152,14 @@ module Samagotchi
142
152
  public_url: public_url, start_dir: start_dir }
143
153
  started = if plan
144
154
  with_tunnel(provider, plan) do |up|
145
- start(**options, public_notes: provider.start_lines(plan), on_bound: up)
155
+ start(**options, public_notes: provider.start_lines(plan, restarted: restarted), on_bound: up)
146
156
  end
147
157
  else
148
158
  start(**options)
149
159
  end
160
+ handed_on = !!started
161
+ return reexec(restart_argv, provider: provider, plan: plan, exec: exec) if started == :restart
162
+
150
163
  started ? 0 : 1
151
164
  end
152
165
  rescue StartDir::Invalid, Lan::Error, Grants::Insecure, PublicUrl::Invalid, Tunnel::Refused, Push::Refused, Push::Store::Insecure => e
@@ -157,6 +170,11 @@ module Samagotchi
157
170
  # status a shell gives a command it interrupted.
158
171
  puts "#{"\n" if $stdout.tty?}Chi Web stopped."
159
172
  CLI::Exit::INTERRUPTED
173
+ ensure
174
+ # A restarted chi web that didn't get to serve (a refusal, the port
175
+ # race, a bad start folder): the old process's Serve handler it was
176
+ # to adopt goes off, never left forwarding to nothing.
177
+ stop_restarted_tunnel(tunnel, resolve_port(port)) if restarted && tunnel && !handed_on
160
178
  end
161
179
 
162
180
  # A second chi web given a start folder (--start-dir, web.start_dir)
@@ -177,16 +195,87 @@ module Samagotchi
177
195
  # (its command runs in its own process group, and Serve keeps what it
178
196
  # set even if chi stopped waiting), so it can't land between the serve
179
197
  # going on and the teardown being due; #up is bounded by its timeout.
198
+ # A :restart result leaves Serve up for the exec'd chi web to adopt.
180
199
  def self.with_tunnel(provider, plan)
181
200
  attempted = false
182
- yield(lambda do
201
+ result = yield(lambda do
183
202
  Thread.handle_interrupt(SignalException => :never) do
184
203
  attempted = true
185
204
  provider.up(plan)
186
205
  end
187
206
  end)
207
+ result
188
208
  ensure
189
- tunnel_down(provider, plan) if attempted
209
+ tunnel_down(provider, plan) if attempted && result != :restart
210
+ end
211
+
212
+ # Replace this process only after its listener and hub have stopped.
213
+ # Starter PIDs are children of this process; workers are detached and
214
+ # must not be adopted or signaled.
215
+ # No line before the exec: one on a hung-up terminal raises EIO, and
216
+ # the restart would end in a dead chi web; the log says it.
217
+ def self.reexec(argv, provider: nil, plan: nil, exec: nil)
218
+ program = reexec_program
219
+ raise ArgumentError, "RubyGems chi wrapper not found" if program.to_s.empty?
220
+
221
+ env = ChildEnv.unbundled.merge(
222
+ RESTARTED_ENV => "1",
223
+ ADOPT_PIDS_ENV => SessionManager.live_spawn_pids.join(",")
224
+ )
225
+ Samagotchi::Log.info(:web, "restart", program: program)
226
+ runner = exec || ->(environment, *args) { Kernel.exec(environment, *args) }
227
+ runner.call(env, RbConfig.ruby, program, *argv)
228
+ rescue SystemCallError, ArgumentError => e
229
+ tunnel_down(provider, plan) if provider && plan
230
+ warn "Error: couldn't restart chi web: #{e.message}"
231
+ 1
232
+ end
233
+
234
+ # What a restart execs: the installed gem's wrapper (it activates the
235
+ # newest installed chi), this bin/chi in a checkout; nil for a gem
236
+ # installed without one (--bindir).
237
+ def self.reexec_program
238
+ gem_spec = InstalledGem.spec
239
+ gem_spec ? InstalledGem.wrapper(gem_spec) : $PROGRAM_NAME
240
+ end
241
+
242
+ # bin/chi's first step: the restart marker and the starters to adopt
243
+ # out of +env+, before anything (a worker, chi update) is spawned with
244
+ # it; the starters are adopted only under the marker.
245
+ # @return [Boolean] whether this chi web is a restarted one
246
+ def self.take_restart_env!(env = ENV, detach: Process.method(:detach))
247
+ restarted = env.delete(RESTARTED_ENV) == "1"
248
+ adopt_reexec_children!(env: env, detach: detach) if restarted
249
+ env.delete(ADOPT_PIDS_ENV)
250
+ restarted
251
+ end
252
+
253
+ # Adopt only the recorded child starters. They may have exited in the
254
+ # interval between collecting their PIDs and the new process starting.
255
+ def self.adopt_reexec_children!(env: ENV, detach: Process.method(:detach))
256
+ pids = env.delete(ADOPT_PIDS_ENV).to_s
257
+ pids.split(",").reject(&:empty?).each do |pid|
258
+ child_pid = Integer(pid, 10)
259
+ begin
260
+ detach.call(child_pid)
261
+ rescue Errno::ECHILD
262
+ nil
263
+ end
264
+ end
265
+ end
266
+
267
+ # Turns off the Serve handler a restarted chi web was to adopt, only
268
+ # when it is still exactly this port's (prepare says adopted); a
269
+ # foreign one, or none, stays as it is. Never raises: the start's own
270
+ # error is the one to say.
271
+ def self.stop_restarted_tunnel(tunnel, port)
272
+ provider = Tunnel.for(tunnel)
273
+ plan = provider.prepare(port: port, config_owner: Samagotchi::Config.get("web.proxy.owner"),
274
+ config_public_url: Samagotchi::Config.get("web.public_url"),
275
+ config_identity_header: Samagotchi::Config.get("web.proxy.identity_header"))
276
+ tunnel_down(provider, plan) if plan.adopted
277
+ rescue StandardError => e
278
+ Samagotchi::Log.warn(:web, "restart_tunnel_left", error: e.class.name, msg: e.message)
190
279
  end
191
280
 
192
281
  # The tunnel's teardown, before any line (a hung-up terminal can't
@@ -230,7 +319,8 @@ module Samagotchi
230
319
  # @param on_bound [#call, nil] run once the port is bound, before any
231
320
  # line (the tunnel's #up); what it raises ends the run
232
321
  # @param start_dir [String] the folder chi web starts in (App's)
233
- # @return [Boolean] false when the port was taken (said so on stderr)
322
+ # @return [Boolean, :restart] false when the port was taken (said so on stderr);
323
+ # :restart when the owner requested an in-place process replacement
234
324
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
235
325
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
236
326
  editor: Config::BY_KEY["web.editor"].default, token_path: Token.path,
@@ -252,6 +342,25 @@ module Samagotchi
252
342
  app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, view: view,
253
343
  annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set,
254
344
  public_url: public_url, public_token: public_token, push: push, start_dir: start_dir)
345
+ webrick = nil
346
+ restart_mutex = Mutex.new
347
+ restart_requested = false
348
+ restart_thread = nil
349
+ if app.respond_to?(:restart_web=)
350
+ app.restart_web = lambda do
351
+ restart_mutex.synchronize do
352
+ next false if restart_requested
353
+ next :no_wrapper if reexec_program.to_s.empty?
354
+
355
+ restart_requested = true
356
+ restart_thread = Thread.new do
357
+ sleep 0.2
358
+ webrick&.shutdown
359
+ end
360
+ true
361
+ end
362
+ end
363
+ end
255
364
  Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
256
365
  Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
257
366
  if public_url
@@ -273,7 +382,6 @@ module Samagotchi
273
382
  # Said once the port is bound: a second chi web racing for it gets
274
383
  # the in-use line instead.
275
384
  running_path = Running.path(state_dir: state_dir)
276
- webrick = nil
277
385
  callback_unfinished = false
278
386
  started = lambda do
279
387
  callback_unfinished = true
@@ -305,7 +413,7 @@ module Samagotchi
305
413
  app.server_running = -> { server.status == :Running }
306
414
  listen_lan(server, lan, port) if lan
307
415
  end
308
- true
416
+ restart_requested ? :restart : true
309
417
  rescue Errno::EADDRINUSE
310
418
  warn in_use_message(port)
311
419
  false
@@ -322,6 +430,7 @@ module Samagotchi
322
430
  update_check&.stop
323
431
  hub&.stop
324
432
  Samagotchi::Log.info(:web, "stop") if app
433
+ restart_thread&.join
325
434
  end
326
435
 
327
436
  class LanListenError < StandardError; end
@@ -80,6 +80,7 @@ module Samagotchi
80
80
  @installed = nil
81
81
  @available = nil
82
82
  @update_blocked = nil
83
+ @update_state = nil
83
84
  @manager = manager
84
85
  @now = now || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
85
86
  @scan_interval = scan_interval
@@ -142,6 +143,16 @@ module Samagotchi
142
143
  @monitor.synchronize { @installed }
143
144
  end
144
145
 
146
+ # Look at what is installed now, not at the next full probe (after an
147
+ # install): a change is the usual `chi` event.
148
+ # @return [String, nil] the newest installed chi
149
+ def refresh_installed
150
+ @monitor.synchronize do
151
+ check_installed
152
+ @installed
153
+ end
154
+ end
155
+
145
156
  # The complete chi version state used by event snapshots and info routes.
146
157
  def chi_state
147
158
  @monitor.synchronize do
@@ -161,6 +172,23 @@ module Samagotchi
161
172
  nil
162
173
  end
163
174
 
175
+ # Publish the owner-triggered update's progress or result for current
176
+ # tabs, and retain the latest state for a reconnect's snapshot.
177
+ def publish_update(state:, rows:, progress: nil, operation: nil, version: nil)
178
+ @monitor.synchronize do
179
+ @update_state = { state: state, rows: rows }
180
+ @update_state[:operation] = operation unless operation.nil?
181
+ @update_state[:version] = version unless version.nil?
182
+ @update_state[:progress] = progress unless progress.nil?
183
+ emit("chi_update", @update_state)
184
+ end
185
+ nil
186
+ end
187
+
188
+ def update_state
189
+ @monitor.synchronize { @update_state&.dup }
190
+ end
191
+
164
192
  # Rescan one session now (its file, its folder, its owner), for the
165
193
  # page's own actions: no waiting for the next tick.
166
194
  def touch(id)
@@ -162,10 +162,16 @@ module Samagotchi
162
162
 
163
163
  # What the start lines add: how it is reached and whose login is the
164
164
  # owner, and the Tailnet Lock warning.
165
- def start_lines(plan)
165
+ def start_lines(plan, restarted: false)
166
166
  source = plan.owner_source == :config ? "web.proxy.owner" : "this machine's Tailscale login"
167
167
  via = " via Tailscale Serve (tailnet only), owner #{plan.public_url.owner} (#{source})"
168
- via += "; Serve already forwarded port 443 here, chi web turns it off on exit" if plan.adopted
168
+ if plan.adopted
169
+ via += if restarted
170
+ "; adopted by the restarted chi web; it turns Serve off on exit"
171
+ else
172
+ "; Serve already forwarded port 443 here, chi web turns it off on exit"
173
+ end
174
+ end
169
175
  lines = [via]
170
176
  if plan.lock_off
171
177
  lines << "Tailnet Lock is off: a device Tailscale's servers add to your tailnet could reach this page; " \
@@ -93,6 +93,19 @@ module Samagotchi
93
93
  end
94
94
  end
95
95
 
96
+ # Why chi web can't install a gem here (the notice's and POST /api/update's
97
+ # one answer), or nil: a source checkout, a Bundler run, a gem dir it
98
+ # can't write.
99
+ # @return [String, nil]
100
+ def self.blocked_reason(gem_spec: InstalledGem.spec, bundler: nil, gem_dir: Gem.dir)
101
+ bundler = defined?(::Bundler) || !ENV["BUNDLE_GEMFILE"].to_s.empty? if bundler.nil?
102
+ return "checkout" unless gem_spec
103
+ return "bundler" if bundler
104
+ return "gem_dir_not_writable" unless File.writable?(gem_dir)
105
+
106
+ nil
107
+ end
108
+
96
109
  private
97
110
 
98
111
  def run_loop
@@ -141,13 +154,7 @@ module Samagotchi
141
154
  version if version && Gem::Version.correct?(version) && Gem::Version.new(version) > version_floor
142
155
  end
143
156
 
144
- def blocked_reason
145
- return "checkout" unless @gem_spec
146
- return "bundler" if @bundler
147
- return "gem_dir_not_writable" unless File.writable?(@gem_dir)
148
-
149
- nil
150
- end
157
+ def blocked_reason = self.class.blocked_reason(gem_spec: @gem_spec, bundler: @bundler, gem_dir: @gem_dir)
151
158
 
152
159
  def load_state
153
160
  return { checked_at: nil, available: nil, notified: [] } unless File.file?(@state_file)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.123.0
4
+ version: 0.124.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -321,6 +321,7 @@ files:
321
321
  - lib/samagotchi/desktop/macos/Models.swift
322
322
  - lib/samagotchi/desktop/macos/Panel.swift
323
323
  - lib/samagotchi/desktop/macos/ProcessRunner.swift
324
+ - lib/samagotchi/desktop/macos/Sessions.swift
324
325
  - lib/samagotchi/desktop/macos/Tmux.swift
325
326
  - lib/samagotchi/desktop_command.rb
326
327
  - lib/samagotchi/edit_preview.rb
@@ -864,6 +865,7 @@ files:
864
865
  - lib/samagotchi/web/public/turn_view.js
865
866
  - lib/samagotchi/web/public/unqueue.js
866
867
  - lib/samagotchi/web/public/update_notice.js
868
+ - lib/samagotchi/web/public/update_sheet.js
867
869
  - lib/samagotchi/web/public/vendor/directive-helpers.js
868
870
  - lib/samagotchi/web/public/vendor/directive.js
869
871
  - lib/samagotchi/web/public/vendor/directives/repeat.js