yamine 0.18.0 → 0.20.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43b8c059b0635ce4272b806fd2b8f70ae42ed6d591ed87536de9c86ed0419864
4
- data.tar.gz: c34fb3a40d5c16442accee4dcbdb32b1b2b5013de5c3a823bade745b6ac65e88
3
+ metadata.gz: c48a7ce222b3ffe113f542e81d84aca45270e4c3ea4f4f7da74bd22e8d0747d7
4
+ data.tar.gz: 8a8c1ad0f17b8bd741723cb9d83100248c559f5c7c995d64f30abfcebfe4fa60
5
5
  SHA512:
6
- metadata.gz: 70495a26f226c3b0dac853c5a4ce1694207f4a9bbb73af92b44b37cc17ff3c13430af7440252102b8acd40f22ee7efdae5dfec26155678a5839e30b10c5c0d1a
7
- data.tar.gz: e450abc81d00dad86bf03e540304ad43683e99329a60eb24ddea0122760048c88729e7fe33c9b93056a1840ec871e488ea458d1afdbd655b0dccd477369b8cb5
6
+ metadata.gz: c9c3ae18ac29e5a30b378d7b70c7dd6c538b9140b058f4308ee2d9e5075567cde9ea21d169d30c54808d6a06a4283d3454d2a652b4b254b40e406d2de4a7edbc
7
+ data.tar.gz: 594bf48948e649c390aa3aed20246abd1c0eabbd0889f2a159f3b4a35d77b0d8cf8874b1eb1f8ebb51be9b0dc4300879139b943a35bde12fbc421a789b08f809
data/CHANGELOG.md CHANGED
@@ -2,6 +2,71 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.20.0] — 2026-09-28
6
+
7
+ ### Added
8
+
9
+ - **Every proxy socket wait is bounded by an idle timeout
10
+ (`YAMINE_PROXY_IDLE_TIMEOUT`, default 60s).** Silence is capped,
11
+ total duration is not: the timer resets on each byte transferred, so
12
+ a slow-but-moving transfer runs as long as it needs while a stalled
13
+ peer or a stale keep-alive socket is dropped instead of pinning a
14
+ thread until the proxy stops answering.
15
+ - **`yamine doctor` gains a "serving ca" check.** It verifies the
16
+ certificate the proxy actually serves against the CA now on disk and
17
+ names the fix when they disagree — so a regenerated CA shows up in
18
+ doctor instead of surfacing later as `ERR_CERT_AUTHORITY_INVALID` in
19
+ the browser.
20
+
21
+ ### Changed
22
+
23
+ - **`yamine proxy start` no longer spawns a second proxy when one
24
+ already serves the port.** It reports that idempotently (new
25
+ `ProxyAlreadyRunningError`) instead of recording a dead PID over
26
+ real state — the shape that left the recorded PID pointing at
27
+ nothing while the real proxy kept serving.
28
+ - **`yamine proxy start` on a privileged port elevates the way the
29
+ boot path does.** One sudo prompt when interactive, or a failure
30
+ pointing at `yamine setup` when not — never a doomed unprivileged
31
+ child that dies on `EACCES` and leaves its PID behind.
32
+ - **`yamine proxy stop` tells the truth about a proxy it cannot
33
+ signal.** On a dead recorded PID it probes the port first: a proxy
34
+ still serving — the root launchd/systemd service, which the CLI
35
+ cannot kill — yields a "still serving" answer with the restart
36
+ command and the recorded state kept, instead of "Removed stale
37
+ proxy state" while the proxy kept running.
38
+ - **`yamine clean` refuses while a proxy it cannot stop is serving.**
39
+ It exits 1 instead of untrusting the CA and deleting the state
40
+ directory out from under a live root service.
41
+ - **A running proxy reloads the signing CA when the pair on disk
42
+ changes.** It re-reads the CA files and drops cached host
43
+ certificates, so a regenerated CA takes effect without a restart —
44
+ previously only a restart fixed it, and until then the proxy kept
45
+ signing certificates no browser trusts.
46
+ - **The TLS handshake moved out of the acceptor into the connection
47
+ thread.** A client that connects and sends nothing can no longer pin
48
+ an acceptor; acceptors now survive per-connection errors and keep
49
+ accepting.
50
+
51
+ ### Fixed
52
+
53
+ - **A spawned daemon that dies before serving fails immediately with
54
+ the proxy log tail instead of a timeout.** A child killed by
55
+ `EACCES` on a privileged port (or anything else fatal at bind)
56
+ used to burn the whole wait and then record a PID that was never
57
+ alive; it now fails fast and records nothing.
58
+
59
+ ## [0.19.0] — 2026-09-27
60
+
61
+ ### Added
62
+
63
+ - **`yamine worktree info <name> [--json]`** — detailed view of a single
64
+ worktree: branch, HEAD (short SHA + subject), author, commit date,
65
+ dirty/merged status, and any database claims. `--json` emits the same
66
+ data as a machine-readable hash. Follows the same safety rails as
67
+ `list`: main checkout is always clean, detached HEADs report n/a for
68
+ merged status, and the path is resolved to its real location.
69
+
5
70
  ## [0.18.0] — 2026-09-23
6
71
 
7
72
  ### Changed
data/lib/yamine/certs.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "fileutils"
4
5
  require "openssl"
5
6
 
@@ -170,23 +171,32 @@ module Yamine
170
171
  end
171
172
 
172
173
  # Build an SSLContext whose SNI callback serves the right cert per host.
174
+ #
175
+ # The CA pair on disk can be regenerated under a live proxy (`yamine
176
+ # clean`/`trust`/`setup` rewrite it). A proxy that keeps signing with
177
+ # its boot-time CA then mints leaves no browser trusts
178
+ # (ERR_CERT_AUTHORITY_INVALID against the new CA), so the SNI
179
+ # callback — which already runs per handshake — reloads the pair
180
+ # when it changes and drops every leaf minted from the old one.
173
181
  def server_context(dir = state_dir)
174
- ca_cert, ca_key = load_ca(dir)
182
+ watcher = CaWatcher.new(dir)
175
183
  cache = CertCache.new(CACHE_SIZE)
184
+ watcher.on_reload = -> { cache.clear }
176
185
  ctx = OpenSSL::SSL::SSLContext.new
177
- ctx.cert = ca_cert
178
- ctx.key = ca_key
186
+ ctx.cert, ctx.key = watcher.current
179
187
  # ruby-openssl versions differ in how the callback receives its
180
188
  # arguments: [[socket, name]] (one array arg), (socket, name), or
181
189
  # (name). Flatten defensively — a raise inside the callback
182
190
  # surfaces as an unrecognized-name handshake alert.
183
191
  ctx.servername_cb = lambda do |*args|
184
192
  host = Array(args).flatten.last.to_s.downcase
185
- entry = cache.fetch(host) do
186
- cert, key = mint_host(host, ca_cert, ca_key)
187
- [cert, key]
193
+ watcher.with_current do |ca_cert, ca_key|
194
+ entry = cache.fetch(host) do
195
+ cert, key = mint_host(host, ca_cert, ca_key)
196
+ [cert, key]
197
+ end
198
+ entry ? OpenSSL::SSL::SSLContext.new.tap { |c| c.cert, c.key = entry } : nil
188
199
  end
189
- entry ? OpenSSL::SSL::SSLContext.new.tap { |c| c.cert, c.key = entry } : nil
190
200
  end
191
201
  ctx
192
202
  end
@@ -252,6 +262,96 @@ module Yamine
252
262
  def size
253
263
  @mutex.synchronize { @store.size }
254
264
  end
265
+
266
+ # Every entry is signed by one CA generation; a reload must not
267
+ # leave old-CA leaves behind to be served after the swap.
268
+ def clear
269
+ @mutex.synchronize do
270
+ @store.clear
271
+ @order.clear
272
+ end
273
+ end
274
+ end
275
+
276
+ # The CA pair a live server_context signs with, reloaded when the
277
+ # files on disk change. The hot path is two stat calls per
278
+ # handshake; the pair is only re-read (and the leaf cache only
279
+ # dropped) when a stat snapshot moves AND the digest differs, so a
280
+ # touch without content change costs nothing. One mutex around
281
+ # check+reload+mint keeps a swap from racing a handshake into a
282
+ # mixed pair or a stale cache hit. A failed reload keeps serving
283
+ # the previous CA — a raise inside the SNI callback would abort
284
+ # the handshake outright.
285
+ class CaWatcher
286
+ def initialize(dir)
287
+ @dir = dir
288
+ @mutex = Mutex.new
289
+ @cert, @key = Certs.load_ca(dir)
290
+ @stat = stat_snapshot
291
+ @digest = pair_digest
292
+ @on_reload = nil
293
+ end
294
+
295
+ # Called (once per swap, under the lock) after a reload. The
296
+ # server_context sets this to drop its leaf cache.
297
+ attr_writer :on_reload
298
+
299
+ def current
300
+ [@cert, @key]
301
+ end
302
+
303
+ def with_current
304
+ @mutex.synchronize do
305
+ reload_if_changed!
306
+ yield @cert, @key
307
+ end
308
+ end
309
+
310
+ private
311
+
312
+ def paths
313
+ Certs.ca_paths(@dir)
314
+ end
315
+
316
+ def stat_snapshot
317
+ [paths[:cert], paths[:key]].map do |path|
318
+ stat = File.stat(path)
319
+ [stat.mtime, stat.size]
320
+ end
321
+ rescue SystemCallError
322
+ nil
323
+ end
324
+
325
+ def pair_digest
326
+ Digest::SHA256.hexdigest(File.read(paths[:cert]) + File.read(paths[:key]))
327
+ rescue SystemCallError, OpenSSL::OpenSSLError
328
+ nil
329
+ end
330
+
331
+ def reload_if_changed!
332
+ snapshot = stat_snapshot
333
+ return if !snapshot.nil? && snapshot == @stat
334
+
335
+ digest = pair_digest
336
+ if !digest.nil? && digest == @digest
337
+ # Touched but unchanged: adopt the snapshot so the hot path
338
+ # goes back to two stats instead of a digest per handshake.
339
+ @stat = snapshot
340
+ return
341
+ end
342
+
343
+ # The serving SSLContext is frozen once the server accepts, so
344
+ # the default certificate cannot be swapped mid-flight — and it
345
+ # does not need to be: every real client sends SNI, and the
346
+ # per-host context this callback returns carries the new CA.
347
+ cert, key = Certs.load_ca(@dir)
348
+ @cert, @key = cert, key
349
+ @stat = stat_snapshot
350
+ @digest = digest || pair_digest
351
+ @on_reload&.call
352
+ rescue CertError, OpenSSL::OpenSSLError, SystemCallError
353
+ nil
354
+ end
255
355
  end
256
356
  end
257
357
  end
@@ -30,7 +30,16 @@ module Yamine
30
30
  end
31
31
 
32
32
  def clean(ctx, _args)
33
- ProxyControl.stop(ctx.store)
33
+ # A root service the CLI cannot signal keeps serving past stop:
34
+ # deleting the state dir (and untrusting the CA) then would
35
+ # orphan the live proxy and break its TLS. Refuse with the fix
36
+ # instead; the state on disk still describes the live proxy.
37
+ if ProxyControl.stop(ctx.store) == :needs_root
38
+ $stderr.puts "Cannot clean: the proxy runs as root (the launchd/systemd service) and is still serving."
39
+ $stderr.puts " Restart it: sudo launchctl kickstart -k system/#{LAUNCHD_LABEL}"
40
+ $stderr.puts " Or uninstall it: sudo yamine service uninstall — then re-run `yamine clean`."
41
+ exit 1
42
+ end
34
43
  result = Trust.untrust
35
44
  puts "CA removed from trust store." if result[:removed]
36
45
  warn "CA untrust failed: #{result[:error]}" if result[:error]
@@ -91,7 +100,33 @@ module Yamine
91
100
  Proxy.new(store: ctx.store, port: port, tls: tls,
92
101
  state_dir: ctx.store.dir, supervisor: sup, tlds: tlds).start_foreground
93
102
  else
94
- ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, tlds: tlds)
103
+ # Privileged ports need root exactly like the boot path:
104
+ # spawn elevated when interactive (one sudo prompt),
105
+ # otherwise fail with the setup hint — never a doomed
106
+ # unprivileged child that dies of EACCES.
107
+ privileged = port < 1024 && !ProxyControl.root?
108
+ if privileged && !ctx.interactive?
109
+ $stderr.puts "Error: proxy is not running and port #{port} needs root."
110
+ $stderr.puts " Human: run this once — yamine setup"
111
+ $stderr.puts " Agent/CI: pre-provision passwordless sudo once —"
112
+ $stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
113
+ $stderr.puts " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
114
+ $stderr.puts " Or start the proxy by hand: sudo yamine proxy start"
115
+ exit 1
116
+ end
117
+ begin
118
+ ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls,
119
+ sudo: privileged, tlds: tlds)
120
+ rescue ProxyAlreadyRunningError => e
121
+ # Idempotent: the proxy we wanted is already serving, so
122
+ # report that instead of a false "started".
123
+ puts e.message
124
+ return
125
+ rescue ProxyNotRunningError => e
126
+ $stderr.puts "Error: #{e.message.lines.first&.strip}"
127
+ $stderr.puts " Fix once: yamine setup"
128
+ exit 1
129
+ end
95
130
  puts "Proxy started on port #{port}#{tls ? " (HTTPS)" : " (HTTP)"}."
96
131
  end
97
132
  when "stop"
@@ -102,10 +137,12 @@ module Yamine
102
137
  when :unknown_process then puts "Port in use by an unknown process."
103
138
  when :needs_root
104
139
  # A root service keeps serving; say so rather than implying
105
- # success. Uninstalling is the real answer — signalling it
106
- # would just leave launchd's KeepAlive restarting it.
107
- $stderr.puts "The proxy runs as root (the launchd/systemd service)."
108
- $stderr.puts " Uninstall it: sudo yamine service uninstall"
140
+ # success. Restarting picks up new state, uninstalling
141
+ # removes it — signalling the pid would just leave
142
+ # launchd's KeepAlive restarting it.
143
+ $stderr.puts "The proxy runs as root (the launchd/systemd service) and is still serving."
144
+ $stderr.puts " Restart it: sudo launchctl kickstart -k system/#{LAUNCHD_LABEL}"
145
+ $stderr.puts " Or uninstall it: sudo yamine service uninstall"
109
146
  exit 1
110
147
  end
111
148
  else
@@ -669,6 +706,10 @@ module Yamine
669
706
  end
670
707
  ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: true)
671
708
  wait_for_ours(ctx, port, tls: tls)
709
+ rescue ProxyAlreadyRunningError
710
+ # The root service (or another daemon) already serves 443: that
711
+ # IS the privileged proxy setup wanted — verify and report.
712
+ wait_for_ours(ctx, port, tls: tls)
672
713
  rescue Yamine::ProxyNotRunningError, SystemCallError => e
673
714
  warn " daemon start failed: #{e.message.lines.first&.strip}"
674
715
  false
@@ -52,12 +52,13 @@ module Yamine
52
52
  sub = args.first
53
53
  case sub
54
54
  when "list", nil then list(ctx, args[1..] || [])
55
+ when "info" then info(ctx, args[1..] || [])
55
56
  when "add" then add(ctx, args[1..] || [])
56
57
  when "remove" then remove(ctx, args[1..] || [])
57
58
  when "clean" then clean(ctx, args[1..] || [])
58
59
  else
59
60
  raise Error,
60
- "Usage: yamine worktree list | add <name> [--dir <path>] [--no-install] | " \
61
+ "Usage: yamine worktree list | info <name> [--json] | add <name> [--dir <path>] [--no-install] | " \
61
62
  "remove <name> [--force] | clean [--all] [--dry-run]"
62
63
  end
63
64
  end
@@ -110,6 +111,59 @@ module Yamine
110
111
  line
111
112
  end
112
113
 
114
+ # yamine worktree info <name> [--json]
115
+ #
116
+ # Detailed view of a single worktree: branch, HEAD, author, date,
117
+ # dirty/merged status, and any database claims.
118
+ def info(ctx, args)
119
+ opts, rest = take_flags(args, value_flags: [], flags: %w[--json])
120
+ name = rest.first
121
+ raise Error, "Usage: yamine worktree info <name> [--json]" unless name
122
+
123
+ repo = repo_top || abort_not_in_repo
124
+ entry = Worktrees.find(repo, name)
125
+ unless entry
126
+ raise Error, "no worktree named #{name.inspect} here — `yamine worktree list` shows what exists"
127
+ end
128
+
129
+ map = Database.load_map(ctx.store.dir)
130
+ claim = claim_for(map, entry.path)
131
+ default_branch = Worktrees.default_branch(repo)
132
+
133
+ result = Worktrees.info(repo, entry, default_branch: default_branch)
134
+ result[:databases] = database_names(claim)
135
+
136
+ if opts[:json]
137
+ require "json"
138
+ puts JSON.pretty_generate(result)
139
+ else
140
+ print_info(result)
141
+ end
142
+ end
143
+
144
+ def database_names(claim)
145
+ return [] unless claim
146
+
147
+ info = claim[1]
148
+ names = info.is_a?(Hash) ? info["names"] : nil
149
+ return [claim[0]] unless names.is_a?(Hash) && !names.empty?
150
+
151
+ names.values
152
+ end
153
+
154
+ def print_info(result)
155
+ puts " branch #{result[:branch]}"
156
+ puts " path #{result[:path]}"
157
+ puts " HEAD #{result[:head]}"
158
+ puts " author #{result[:author]}" if result[:author]
159
+ puts " date #{result[:date]}" if result[:date]
160
+ puts " dirty #{result[:dirty] ? "yes" : "no"}"
161
+ puts " merged #{result[:merged].nil? ? "n/a" : (result[:merged] ? "yes" : "no")}"
162
+ if result[:databases] && !result[:databases].empty?
163
+ result[:databases].each { |db| puts " database #{db}" }
164
+ end
165
+ end
166
+
113
167
  # Every database a claim stands for, one per line under the row —
114
168
  # five names inline would unreadable, and hiding them would
115
169
  # re-create the "which database is this worktree even using"
data/lib/yamine/doctor.rb CHANGED
@@ -34,6 +34,7 @@ module Yamine
34
34
  checks << check_dns(store)
35
35
  checks << check_ca
36
36
  checks << check_ca_bundle
37
+ checks << check_serving_ca(port, tls: tls)
37
38
  checks
38
39
  end
39
40
 
@@ -224,6 +225,38 @@ module Yamine
224
225
  Check.new(name: "bundle", ok: false, message: "could not build #{Certs.bundle_path(dir)}: #{e.message}")
225
226
  end
226
227
 
228
+ # The certificate the proxy ACTUALLY serves, verified against the CA
229
+ # now on disk. The CA can be regenerated under a live proxy
230
+ # (`yamine clean`/`trust`/`setup` rewrite the pair); without this
231
+ # check doctor reported "[ok] proxy: listening" and "[ok] ca: CA
232
+ # trusted" while every browser got ERR_CERT_AUTHORITY_INVALID,
233
+ # because nothing compared the served leaf to the current CA.
234
+ def check_serving_ca(port, tls:)
235
+ return Check.new(name: "serving ca", ok: true, message: "TLS off — no serving certificate to check") unless tls
236
+ return Check.new(name: "serving ca", ok: true, message: "no proxy serving — skipped") if port.nil?
237
+
238
+ dir = Certs.state_dir
239
+ ca_path = Certs.ca_paths(dir)[:cert]
240
+ return Check.new(name: "serving ca", ok: true, message: "no CA on disk — skipped") unless File.file?(ca_path)
241
+
242
+ leaf = Trust.proxy_peer_cert(port)
243
+ return Check.new(name: "serving ca", ok: true, warn: true,
244
+ message: "could not read the proxy's serving certificate — skipped") if leaf.nil?
245
+
246
+ ca = OpenSSL::X509::Certificate.new(File.read(ca_path))
247
+ if Trust.signed_by?(leaf, ca)
248
+ Check.new(name: "serving ca", ok: true, message: "serving certificate matches the CA on disk")
249
+ else
250
+ Check.new(name: "serving ca", ok: false,
251
+ message: "proxy serves a certificate signed by a superseded CA — " \
252
+ "restart it: sudo launchctl kickstart -k system/dev.yamine " \
253
+ "(or sudo yamine service install to re-register the service after a gem upgrade)")
254
+ end
255
+ rescue StandardError => e
256
+ Check.new(name: "serving ca", ok: true, warn: true,
257
+ message: "could not check the serving certificate (#{e.message}) — skipped")
258
+ end
259
+
227
260
  # How many trusted certificates carry one of our CA names without
228
261
  # being the CA currently on disk. Best-effort: 0 when the keychain
229
262
  # cannot be read (doctor must never fail on a read-only probe).
data/lib/yamine/errors.rb CHANGED
@@ -33,5 +33,9 @@ module Yamine
33
33
  end
34
34
  end
35
35
  class ProxyNotRunningError < Error; end
36
+ # spawn_daemon raises this instead of spawning when our proxy already
37
+ # answers on the port: the readiness probe would otherwise pass on the
38
+ # live proxy's behalf and a dead pid would be recorded over real state.
39
+ class ProxyAlreadyRunningError < Error; end
36
40
  class CertError < Error; end
37
41
  end
data/lib/yamine/proxy.rb CHANGED
@@ -23,6 +23,20 @@ module Yamine
23
23
  # Bounded concurrency: beyond this many simultaneous connections the
24
24
  # proxy answers 503 instead of spawning threads without limit.
25
25
  MAX_CONNECTIONS = Integer(ENV.fetch("YAMINE_MAX_CONNECTIONS", "200"))
26
+ # Idle bound: waiting for the NEXT byte on any client or backend
27
+ # socket gives up after this many seconds. The clock resets on every
28
+ # byte transferred, so it caps silence — never total duration. A
29
+ # slow trickle (SSE/NDJSON chat stream, big download) runs as long as
30
+ # bytes keep moving; a peer that goes quiet is dropped instead of
31
+ # pinning a thread. Without this, stale keep-alive sockets and
32
+ # stalled peers accumulate until the proxy stops answering.
33
+ # Named YAMINE_PROXY_IDLE_TIMEOUT (not YAMINE_IDLE_TIMEOUT — that one
34
+ # is the Supervisor's app-idle kill, a different clock entirely).
35
+ IDLE_TIMEOUT = Float(ENV.fetch("YAMINE_PROXY_IDLE_TIMEOUT", "60"))
36
+ # Raised when a peer is silent past the idle bound. An IOError so the
37
+ # existing rescue-and-close paths treat it like any dead peer.
38
+ IdleTimeout = Class.new(IOError)
39
+ CHUNK_BYTES = 16_384
26
40
  # Route cache: routes.json is the source of truth, but re-reading
27
41
  # and re-parsing it on every request is wasteful under HMR polling.
28
42
  # Keyed on file mtime, so a freshly registered route is visible on
@@ -33,7 +47,7 @@ module Yamine
33
47
  LoopDetected = Struct.new(:host, :hops)
34
48
 
35
49
  def initialize(store:, port: 443, tls: true, state_dir: nil, on_error: nil,
36
- supervisor: nil, max_connections: MAX_CONNECTIONS, tlds: nil)
50
+ supervisor: nil, max_connections: MAX_CONNECTIONS, idle_timeout: IDLE_TIMEOUT, tlds: nil)
37
51
  @store = store
38
52
  @port = port
39
53
  @tls = tls
@@ -41,6 +55,7 @@ module Yamine
41
55
  @on_error = on_error || ->(msg) { warn msg }
42
56
  @supervisor = supervisor
43
57
  @max_connections = max_connections
58
+ @idle_timeout = idle_timeout
44
59
  @tlds = Array(tlds).flatten.compact.map(&:downcase)
45
60
  @tlds = [Hostname::DEFAULT_TLD] if @tlds.empty?
46
61
  @inflight = 0
@@ -92,7 +107,17 @@ module Yamine
92
107
  end
93
108
  end
94
109
  rescue OpenSSL::SSL::SSLError, IOError, SystemCallError
95
- break unless @running
110
+ # A per-connection accept failure (bad TLS handshake,
111
+ # reset peer) is one dropped connection, not a reason to
112
+ # stop listening. Only shutdown breaks the loop.
113
+ next if @running
114
+ break
115
+ rescue StandardError => e
116
+ # An unexpected accept error must not silently kill the
117
+ # acceptor either: log it and keep serving while running.
118
+ @on_error.call("accept error: #{e.class}: #{e.message}")
119
+ next if @running
120
+ break
96
121
  end
97
122
  end
98
123
  end
@@ -180,17 +205,29 @@ module Yamine
180
205
  [TCPServer.new("127.0.0.1", @port), TCPServer.new("::1", @port)].map do |server|
181
206
  next server unless @tls
182
207
 
183
- OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir))
208
+ with_deferred_handshake(OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir)))
184
209
  end
185
210
  rescue SystemCallError
186
211
  server = TCPServer.new("127.0.0.1", @port)
187
- @tls ? [OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir))] : [server]
212
+ @tls ? [with_deferred_handshake(OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir)))] : [server]
213
+ end
214
+
215
+ # The TLS handshake reads from the peer, so doing it inside
216
+ # SSLServer#accept would let a connect-and-send-nothing client pin an
217
+ # acceptor thread forever (and with both acceptors pinned the proxy
218
+ # stops answering). Defer it: accept returns after the TCP handshake
219
+ # and the per-connection thread finishes TLS under the idle bound in
220
+ # #handle. SNI/cert behavior is unchanged — only the timing moves.
221
+ def with_deferred_handshake(ssl_server)
222
+ ssl_server.start_immediately = false
223
+ ssl_server
188
224
  end
189
225
 
190
226
  # One connection = many requests (keep-alive). Each request head is
191
227
  # re-parsed and rewritten; bodies are framed by Content-Length;
192
228
  # responses with Content-Length allow the loop to continue.
193
229
  def handle(sock)
230
+ tls_handshake(sock)
194
231
  buf = +""
195
232
  loop do
196
233
  head, buf = read_head(sock, buf)
@@ -256,13 +293,13 @@ module Yamine
256
293
  # the response. Returns :keep_alive when both sides want to reuse
257
294
  # the connection and the response length was known.
258
295
  def pipe_request(sock, backend, method, target, headers, buf)
259
- backend.write(rebuild_head(method, target, headers))
296
+ write_all(backend, rebuild_head(method, target, headers))
260
297
 
261
298
  body_len = request_body_length(headers)
262
299
  if body_len == :chunked
263
300
  # Unclassifiable request body: stream to EOF, close after.
264
- backend.write(buf) unless buf.empty?
265
- IO.copy_stream(sock, backend)
301
+ write_all(backend, buf) unless buf.empty?
302
+ copy_stream(sock, backend)
266
303
  relay_response_close(backend, sock)
267
304
  return :close
268
305
  end
@@ -270,13 +307,11 @@ module Yamine
270
307
  remaining = body_len
271
308
  unless buf.empty?
272
309
  from_buf = buf.byteslice(0, remaining)
273
- backend.write(from_buf)
310
+ write_all(backend, from_buf)
274
311
  remaining -= from_buf.bytesize
275
312
  buf = buf.byteslice(from_buf.bytesize..) || +""
276
313
  end
277
- if remaining > 0
278
- IO.copy_stream(sock, backend, remaining)
279
- end
314
+ copy_stream(sock, backend, remaining) if remaining > 0
280
315
 
281
316
  rhead, rbuf = read_head(backend, +"")
282
317
  if rhead.nil?
@@ -284,19 +319,17 @@ module Yamine
284
319
  return :close
285
320
  end
286
321
  _rm, _rt, rheaders = parse_head(rhead)
287
- sock.write(rhead)
288
- sock.write(rbuf) unless rbuf.empty?
322
+ write_all(sock, rhead)
323
+ write_all(sock, rbuf) unless rbuf.empty?
289
324
 
290
325
  rlen = content_length(rheaders)
291
326
  if rlen.nil?
292
327
  # No Content-Length: close-delimited response.
293
- IO.copy_stream(backend, sock)
328
+ copy_stream(backend, sock)
294
329
  return :close
295
330
  end
296
331
  remaining = rlen - rbuf.bytesize
297
- if remaining > 0
298
- IO.copy_stream(backend, sock, remaining)
299
- end
332
+ copy_stream(backend, sock, remaining) if remaining > 0
300
333
 
301
334
  if keep_alive?(headers) && keep_alive?(rheaders)
302
335
  # Any bytes beyond Content-Length on the backend are a second
@@ -311,9 +344,9 @@ module Yamine
311
344
  rhead, rbuf = read_head(backend, +"")
312
345
  return if rhead.nil?
313
346
 
314
- sock.write(rhead)
315
- sock.write(rbuf) unless rbuf.empty?
316
- IO.copy_stream(backend, sock)
347
+ write_all(sock, rhead)
348
+ write_all(sock, rbuf) unless rbuf.empty?
349
+ copy_stream(backend, sock)
317
350
  rescue IOError, SystemCallError
318
351
  nil
319
352
  end
@@ -331,8 +364,8 @@ module Yamine
331
364
  set_forwarded(headers, sock, tls: @tls)
332
365
 
333
366
  backend = dial(entry)
334
- backend.write(rebuild_head(method, target, headers))
335
- backend.write(buf) unless buf.empty?
367
+ write_all(backend, rebuild_head(method, target, headers))
368
+ write_all(backend, buf) unless buf.empty?
336
369
  pipe_both(sock, backend)
337
370
  rescue SystemCallError, OpenSSL::SSL::SSLError, IOError => e
338
371
  @on_error.call("upgrade proxy error: #{e.message}")
@@ -369,9 +402,11 @@ module Yamine
369
402
  true
370
403
  end
371
404
 
372
- # Read just the header block with readpartial (no stdio buffering,
373
- # so nothing is stolen from the body stream). `buf` carries bytes
374
- # read ahead (pipelined requests) across calls. Returns [head, buf].
405
+ # Read just the header block without stdio buffering (so nothing is
406
+ # stolen from the body stream). `buf` carries bytes read ahead
407
+ # (pipelined requests) across calls. Returns [head, buf]. The wait
408
+ # for more bytes is idle-bounded: a peer that sends nothing is
409
+ # dropped (nil) instead of pinning the thread.
375
410
  def read_head(sock, buf)
376
411
  loop do
377
412
  if (idx = buf.index("\r\n\r\n"))
@@ -379,13 +414,109 @@ module Yamine
379
414
  end
380
415
  return [nil, buf] if buf.bytesize > MAX_HEAD_BYTES
381
416
 
382
- chunk = sock.readpartial(16_384)
383
- buf << chunk
417
+ buf << read_chunk(sock, CHUNK_BYTES)
384
418
  end
385
419
  rescue EOFError, IOError
386
420
  [nil, +""]
387
421
  end
388
422
 
423
+ # Finish a deferred TLS handshake under the idle bound. A no-op for
424
+ # plain sockets and for SSLSockets that already handshook (a second
425
+ # accept on an established connection returns immediately).
426
+ def tls_handshake(sock)
427
+ return unless sock.is_a?(OpenSSL::SSL::SSLSocket)
428
+
429
+ loop do
430
+ result = sock.accept_nonblock(exception: false)
431
+ return if result.is_a?(OpenSSL::SSL::SSLSocket)
432
+
433
+ wait_for(sock, result)
434
+ end
435
+ end
436
+
437
+ # One idle-bounded read: returns bytes, raises EOFError at end of
438
+ # stream, IdleTimeout when the peer is silent past the bound. Never
439
+ # blocks in the kernel without a select deadline, so no stalled peer
440
+ # pins the thread — including mid-TLS-handshake stalls, which plain
441
+ # readpartial would ride out forever.
442
+ def read_chunk(sock, size)
443
+ loop do
444
+ result = sock.read_nonblock(size, exception: false)
445
+ return result if result.is_a?(String)
446
+ raise EOFError, "end of file reached" if result.nil?
447
+
448
+ wait_for(sock, result)
449
+ end
450
+ end
451
+
452
+ # Idle-bounded write of the whole string. A peer that stops reading
453
+ # (full window) stalls here only until the bound, then IdleTimeout.
454
+ def write_all(sock, data)
455
+ offset = 0
456
+ while offset < data.bytesize
457
+ result = sock.write_nonblock(data.byteslice(offset..), exception: false)
458
+ if result.is_a?(Integer)
459
+ # A zero write made no progress: wait like a blocked writer
460
+ # instead of spinning.
461
+ result.zero? ? wait_for(sock, :wait_writable) : offset += result
462
+ else
463
+ wait_for(sock, result)
464
+ end
465
+ end
466
+ offset
467
+ end
468
+
469
+ # Idle-bounded relay. length nil copies to EOF (close-delimited or
470
+ # chunked-upload bodies); otherwise exactly length bytes. Every byte
471
+ # that moves resets the idle clock, so a slow trickle survives while
472
+ # a stalled source is abandoned with IdleTimeout. A truncated fixed
473
+ # body re-raises EOFError like IO.copy_stream did.
474
+ def copy_stream(src, dst, length = nil)
475
+ remaining = length
476
+ loop do
477
+ break if !remaining.nil? && remaining <= 0
478
+
479
+ size = remaining.nil? ? CHUNK_BYTES : [CHUNK_BYTES, remaining].min
480
+ begin
481
+ data = read_chunk(src, size)
482
+ rescue EOFError
483
+ raise if !remaining.nil? && remaining.positive?
484
+
485
+ break
486
+ end
487
+ write_all(dst, data)
488
+ remaining -= data.bytesize unless remaining.nil?
489
+ end
490
+ end
491
+
492
+ # Block until the socket is ready for the direction a nonblocking op
493
+ # asked for; IdleTimeout when the bound passes with no progress.
494
+ def wait_for(sock, wait_kind)
495
+ if wait_kind == :wait_readable
496
+ raise IdleTimeout, "idle timeout after #{@idle_timeout}s with no bytes" unless readable?(sock)
497
+ elsif !writable?(sock)
498
+ raise IdleTimeout, "idle timeout after #{@idle_timeout}s with no bytes"
499
+ end
500
+ nil
501
+ end
502
+
503
+ # select(2) with the idle bound. SSL-buffered bytes count as
504
+ # readable without a syscall. A closed socket raises in select —
505
+ # report not-ready and let the nonblocking op raise the real error.
506
+ def readable?(sock)
507
+ return true if sock.respond_to?(:pending) && sock.pending.positive?
508
+
509
+ !IO.select([sock], nil, nil, @idle_timeout).nil?
510
+ rescue IOError, SystemCallError
511
+ false
512
+ end
513
+
514
+ def writable?(sock)
515
+ !IO.select(nil, [sock], nil, @idle_timeout).nil?
516
+ rescue IOError, SystemCallError
517
+ false
518
+ end
519
+
389
520
  def parse_head(head)
390
521
  lines = head.split("\r\n")
391
522
  method, target, _version = lines.first.to_s.split(" ", 3)
@@ -432,7 +563,9 @@ module Yamine
432
563
  end
433
564
 
434
565
  # Raw bidirectional pump for upgrades; either side closing unblocks
435
- # the other.
566
+ # the other. Deliberately outside the idle bound: an idle websocket
567
+ # is legitimate traffic (silence is the normal state), and framing
568
+ # no longer applies once the connection is hijacked.
436
569
  def pipe_both(client, backend)
437
570
  t1 = Thread.new do
438
571
  IO.copy_stream(client, backend)
@@ -508,11 +641,11 @@ module Yamine
508
641
 
509
642
  def respond(sock, status, body)
510
643
  message = { 404 => "Not Found", 502 => "Bad Gateway", 508 => "Loop Detected" }[status]
511
- sock.write("HTTP/1.1 #{status} #{message}\r\n" \
512
- "Content-Type: text/html\r\n" \
513
- "#{HEALTH_HEADER}: 1\r\n" \
514
- "Content-Length: #{body.bytesize}\r\n" \
515
- "Connection: close\r\n\r\n#{body}")
644
+ write_all(sock, "HTTP/1.1 #{status} #{message}\r\n" \
645
+ "Content-Type: text/html\r\n" \
646
+ "#{HEALTH_HEADER}: 1\r\n" \
647
+ "Content-Length: #{body.bytesize}\r\n" \
648
+ "Connection: close\r\n\r\n#{body}")
516
649
  rescue IOError, SystemCallError
517
650
  nil
518
651
  end
@@ -247,6 +247,13 @@ module Yamine
247
247
  # explicitly because sudo does not preserve the environment.
248
248
  # Returns pid.
249
249
  def spawn_daemon(store:, port:, tls:, sudo: false, tlds: nil)
250
+ # Never spawn over a live proxy: the readiness probe below would
251
+ # pass on ITS behalf while our child dies of EACCES, and the dead
252
+ # pid would then be recorded over the real proxy's state.
253
+ if ours?(port, tls: tls)
254
+ raise ProxyAlreadyRunningError,
255
+ "A yamine proxy is already serving on port #{port} — not starting another."
256
+ end
250
257
  store.ensure_dir
251
258
  log_path = File.join(store.dir, LOG_NAME)
252
259
  Log.rotate(log_path)
@@ -261,6 +268,14 @@ module Yamine
261
268
  Process.detach(pid)
262
269
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 15
263
270
  until ours?(port, tls: tls)
271
+ # The child died before serving (EACCES on a privileged port,
272
+ # EADDRINUSE on a taken one): fail now with its log instead of
273
+ # waiting out the deadline and blaming a timeout.
274
+ unless pid_alive?(pid)
275
+ raise ProxyNotRunningError,
276
+ "Proxy exited before serving on port #{port}. " \
277
+ "Log (#{log_path}):\n#{log_tail(log_path)}"
278
+ end
264
279
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
265
280
  raise ProxyNotRunningError,
266
281
  "Proxy did not start on port #{port}. " \
@@ -269,6 +284,15 @@ module Yamine
269
284
 
270
285
  sleep 0.25
271
286
  end
287
+ # The probe can pass on someone else's behalf (a root service that
288
+ # started answering between our pre-spawn check and now, while our
289
+ # child died): never record a pid that is not alive.
290
+ unless pid_alive?(pid)
291
+ raise ProxyNotRunningError,
292
+ "Proxy process #{pid} is not running, but port #{port} answers. " \
293
+ "Another proxy (likely the root service) is serving it — not recording #{pid}. " \
294
+ "Log (#{log_path}):\n#{log_tail(log_path)}"
295
+ end
272
296
  write_proxy_state(store, pid: pid, port: port, tls: tls)
273
297
  pid
274
298
  end
@@ -296,6 +320,13 @@ module Yamine
296
320
  return :unknown_process
297
321
  end
298
322
  unless pid_alive?(pid)
323
+ # The recorded process is gone, but someone may still be serving
324
+ # the recorded port (a root service the CLI cannot signal):
325
+ # clearing the state then would orphan the live proxy, leaving
326
+ # nothing on disk to find or stop it by. Probe first.
327
+ if port && ours?(port, tls: proxy_tls(store))
328
+ return :needs_root
329
+ end
299
330
  clear_pid(store)
300
331
  return :stale
301
332
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.18.0"
4
+ VERSION = "0.20.0"
5
5
  end
@@ -52,6 +52,36 @@ module Yamine
52
52
  entries
53
53
  end
54
54
 
55
+ # Detailed information about a single worktree. Returns a Hash with
56
+ # keys: :branch, :path, :head, :author, :date, :dirty, :merged.
57
+ def info(dir, entry, default_branch: nil)
58
+ repo = dir
59
+ branch_name = entry.main? ? "main" : (entry.branch || "(detached)")
60
+
61
+ head, subject, author_name, author_email, date = commit_info(entry.path)
62
+
63
+ dirty = entry.exists? &&
64
+ !entry.main? &&
65
+ dirty?(entry.path, ignore: [])
66
+
67
+ merged = if entry.detached || entry.main?
68
+ nil
69
+ else
70
+ db = default_branch || default_branch_for(repo)
71
+ merged?(repo, entry.branch, into: db)
72
+ end
73
+
74
+ {
75
+ branch: branch_name,
76
+ path: File.expand_path(entry.path),
77
+ head: head,
78
+ author: author_name && author_email ? "#{author_name} <#{author_email}>" : nil,
79
+ date: date,
80
+ dirty: dirty,
81
+ merged: merged
82
+ }
83
+ end
84
+
55
85
  # The worktree whose branch or directory basename is `name`.
56
86
  def find(dir, name)
57
87
  list(dir).find do |entry|
@@ -143,5 +173,30 @@ module Yamine
143
173
  rescue SystemCallError, ArgumentError
144
174
  ["", nil]
145
175
  end
176
+
177
+ # Return [short_sha, subject, author_name, author_email, iso_date]
178
+ # for the HEAD commit of `path`. Any field may be nil when git fails.
179
+ def commit_info(path)
180
+ out, status = git(path,
181
+ "log", "-1", "--format=%h\t%s\t%an\t%ae\t%aI")
182
+ return [nil, nil, nil, nil, nil] unless status&.success?
183
+
184
+ parts = out.strip.split("\t", 5)
185
+ [parts[0], parts[1], parts[2], parts[3], parts[4]]
186
+ rescue SystemCallError, ArgumentError
187
+ [nil, nil, nil, nil, nil]
188
+ end
189
+
190
+ # Resolve the default branch for merging comparisons.
191
+ def default_branch_for(repo)
192
+ Variant::DEFAULT_BRANCHES.each do |candidate|
193
+ return candidate if branch_exists?(repo, candidate)
194
+ end
195
+
196
+ out, status = git(repo, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
197
+ return out.strip.sub(%r{\Arefs/remotes/origin/}, "") if status&.success? && !out.strip.empty?
198
+
199
+ "master"
200
+ end
146
201
  end
147
202
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yamine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto