yamine 0.23.0 → 0.23.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: 308817ac04ecc72d2d0af8ca1a9262d502d5dfb27d4b21a11403c12eebac4aa0
4
- data.tar.gz: 0d8c7fdc05ef6bb45ca01165901f14d9d9d17b80e4e86d1c32614fb8f12594a0
3
+ metadata.gz: c868efafc13a1f8bf3aadad4cf4d0cacffe110e51e091e30b79eb1e81d2f4a24
4
+ data.tar.gz: f888229b7d618e2936108a27342f9227c8f986e46611ba22ea880052ca190a49
5
5
  SHA512:
6
- metadata.gz: ffc43e16572dee62e053441f56aa2472fbf3c388b80fdbbae17a79fbae0a4e6fae03819dd72d0830239aaaabfa8d04b1952433083cb9339fb7776b2b2bdbbf5e
7
- data.tar.gz: d51b33e1780b9cd49cc87655bdb51adfc007736c29b67ac580fda2c2126c7cf80471e24fc4446f805f9230435af6e682132eaed599de5d7222e2125cb3c1294b
6
+ metadata.gz: 3ad556cdad8d143e9beb87ba0cdcfc2fa5a686ad04d10ad763e048f4ace90d084527ecc23064998ac37d9a65705e831b3216675ab5306a5da8b869680f302371
7
+ data.tar.gz: b1b11d113410b72139b9f8ea19592342b29b40edab39869861e2488f4c4c8f193de00ad564f2ba771dba5e2a33072abd41c5e0f10c649dd0ff55bceaaeab98ff
data/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.23.1] - 2026-10-02
6
+
7
+ ### Fixed
8
+
9
+ - **A chunked response now ends at its terminator instead of at end of
10
+ file.** The relay had one way to end a body with no Content-Length —
11
+ copy until the backend hung up — and it read every such body as
12
+ close-delimited. But a chunked body ends at its terminating chunk, and
13
+ the backend holding the connection open past it is keep-alive doing
14
+ exactly what it promised. So the relay blocked for the whole idle
15
+ bound, sixty seconds by default, with the client's connection pinned
16
+ the entire time. Every streamed page, every ActionController::Live
17
+ response and every event stream is framed this way, and the request
18
+ that followed on that connection — typically the page's main
19
+ JavaScript chunk — got nothing back until the connection was reaped.
20
+ A load test could not see it, because every request in one opened its
21
+ own connection. Chunked framing is now read rather than assumed, bytes
22
+ still cross to the client exactly as the backend wrote them, and the
23
+ connection is free for the next request the moment the terminator
24
+ arrives.
25
+ - `minitest/mock` is required by the test helper, so `Object#stub` is
26
+ available to every test rather than only the ones that load it.
27
+
5
28
  ## [0.23.0] - 2026-10-02
6
29
 
7
30
  ### Fixed
data/lib/yamine/proxy.rb CHANGED
@@ -64,6 +64,7 @@ module Yamine
64
64
  # which is also the only one a retry can be safe for.
65
65
  HeadTimeout = Class.new(IdleTimeout)
66
66
  CHUNK_BYTES = 16_384
67
+ CRLF = "\r\n"
67
68
  # Route cache: routes.json is the source of truth, but re-reading
68
69
  # and re-parsing it on every request is wasteful under HMR polling.
69
70
  # Keyed on file mtime, so a freshly registered route is visible on
@@ -449,16 +450,26 @@ module Yamine
449
450
  def relay_after_head(sock, backend, rhead, rbuf, rheaders, headers, target)
450
451
  begin
451
452
  write_all(sock, rhead)
452
- write_all(sock, rbuf) unless rbuf.empty?
453
453
 
454
454
  rlen = content_length(rheaders)
455
- if rlen.nil?
456
- # No Content-Length: close-delimited response.
457
- copy_stream(backend, sock)
458
- return [:close, nil]
455
+ if chunked?(rheaders)
456
+ # The chunk relay reads the framing to find where the body ends,
457
+ # so it takes the read-ahead itself — handing it bytes already on
458
+ # the wire would send the body twice.
459
+ relay_chunked(backend, sock, rbuf)
460
+ else
461
+ write_all(sock, rbuf) unless rbuf.empty?
462
+
463
+ if rlen.nil?
464
+ # No Content-Length and no chunk framing: the body really does
465
+ # end at end of file, because the backend is closing to say so.
466
+ copy_stream(backend, sock)
467
+ return [:close, nil]
468
+ else
469
+ remaining = rlen - rbuf.bytesize
470
+ copy_stream(backend, sock, remaining) if remaining > 0
471
+ end
459
472
  end
460
- remaining = rlen - rbuf.bytesize
461
- copy_stream(backend, sock, remaining) if remaining > 0
462
473
  rescue EOFError
463
474
  @on_error.call("backend for #{headers["host"]}#{target} closed mid-body, " \
464
475
  "before its Content-Length of #{rheaders["content-length"]} was met")
@@ -486,8 +497,13 @@ module Yamine
486
497
  return if rhead.nil?
487
498
 
488
499
  write_all(sock, rhead)
489
- write_all(sock, rbuf) unless rbuf.empty?
490
- copy_stream(backend, sock)
500
+ _rm, _rt, rheaders = parse_head(rhead)
501
+ if chunked?(rheaders)
502
+ relay_chunked(backend, sock, rbuf)
503
+ else
504
+ write_all(sock, rbuf) unless rbuf.empty?
505
+ copy_stream(backend, sock)
506
+ end
491
507
  rescue IOError, SystemCallError
492
508
  nil
493
509
  end
@@ -652,6 +668,79 @@ module Yamine
652
668
  end
653
669
  end
654
670
 
671
+ # Chunked framing is the one body the proxy has to read rather than
672
+ # copy, because it is the only thing that says where the body ends. It
673
+ # ends at the terminating chunk — not at end of file, and the backend
674
+ # holding the connection open past it is keep-alive doing exactly what
675
+ # it promised. Every streamed page, every ActionController::Live
676
+ # response and every event stream is framed this way.
677
+ #
678
+ # Bytes cross to the client exactly as the backend wrote them; only the
679
+ # framing is read, never rewritten. `buf` is the read-ahead already
680
+ # past the response head.
681
+ def relay_chunked(src, dst, buf)
682
+ loop do
683
+ line = take_line(src, buf)
684
+ return if line.nil?
685
+
686
+ write_all(dst, line)
687
+ size = chunk_size(line)
688
+ if size.zero?
689
+ relay_trailers(src, dst, buf)
690
+ return
691
+ end
692
+
693
+ write_all(dst, take_bytes(src, buf, size) || +"")
694
+ write_all(dst, take_line(src, buf) || +"")
695
+ end
696
+ end
697
+
698
+ # Trailers run to the blank line that closes them.
699
+ def relay_trailers(src, dst, buf)
700
+ loop do
701
+ line = take_line(src, buf)
702
+ return if line.nil?
703
+
704
+ write_all(dst, line)
705
+ return if line == CRLF
706
+ end
707
+ end
708
+
709
+ # A chunk-size line, less the CRLF that ends it. Extensions after a
710
+ # semicolon are the sender's and are not ours to weigh.
711
+ def chunk_size(line)
712
+ line.split(";", 2).first.to_s.strip.to_i(16)
713
+ end
714
+
715
+ def take_line(sock, buf)
716
+ until (index = buf.index(CRLF))
717
+ return nil unless fill_buf(sock, buf)
718
+ end
719
+ buf.slice!(0, index + 2)
720
+ end
721
+
722
+ def take_bytes(sock, buf, count)
723
+ out = +""
724
+ while out.bytesize < count
725
+ return nil if buf.empty? && !fill_buf(sock, buf)
726
+
727
+ take = [count - out.bytesize, buf.bytesize].min
728
+ out << buf.slice!(0, take)
729
+ end
730
+ out
731
+ end
732
+
733
+ def fill_buf(sock, buf)
734
+ buf << read_chunk(sock, CHUNK_BYTES)
735
+ true
736
+ rescue EOFError
737
+ false
738
+ end
739
+
740
+ def chunked?(headers)
741
+ headers["transfer-encoding"].to_s.downcase.include?("chunked")
742
+ end
743
+
655
744
  # Block until the socket is ready for the direction a nonblocking op
656
745
  # asked for; a timeout when the bound passes with no progress. A
657
746
  # caller-supplied bound only changes how long we wait — what a timeout
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.23.0"
4
+ VERSION = "0.23.1"
5
5
  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.23.0
4
+ version: 0.23.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto