local-development-gateway 0.2.0 → 0.2.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: ad3ccae73de13d5b8f94f5745116e2c87cb323421b0df17efa76376648c5c99f
4
- data.tar.gz: 628768727722325fcb3195d849de79e4c28e62f903618aaa50caf5b40e128500
3
+ metadata.gz: 8baffa29acc52adfcc3112cacc9be936b24528915f86fbc94c323df081e05fd5
4
+ data.tar.gz: 4ab5156e3dc8dae143345b3c58271be2afd4b3485fb92c6bfd18153dd1958734
5
5
  SHA512:
6
- metadata.gz: 29d3dc511c560f8df87b3cb960098084ba177d8b366bfb4cbea142dcaa59433431f9c9add4d823512440729150aec7442c4f1b9372e5ba5a77af1303b46598ac
7
- data.tar.gz: 2e808de3a602575bc91b2585372f240de10ee46f90ec562d3e49619cd8153e5eac463b55697a9cad43558e149426d95ab870a8816e083f321e97944cdcb2d7e6
6
+ metadata.gz: 8ed0938cfb210bda2c320f6c481af5ddabf9f32d8f04fcac7ac94f60bbc9df8da98fe71f7929581d54ab5a20b648c339b92cd78f799b50fb74f3300e64997e0c
7
+ data.tar.gz: b1acdaefc1c2b69b1e101fb5516ee8de7de866e9d9b1d11bd2390d14a860204a869ca8d90a10123a9b304b954fb03297c26b80482743105bee5a53f3bad06c22
data/README.md CHANGED
@@ -143,18 +143,27 @@ connects to `db.issue-567.wrap.localhost` on the driver's standard port:
143
143
  Use the database container's actual internal port in
144
144
  `local-gateway.tcp.port`; it does not need to match the host listener.
145
145
 
146
+ Remove the database's host `ports` publication when using the gateway; the shared
147
+ router owns `127.0.0.1:1433` and `127.0.0.1:5432`. Keep the `default` network so
148
+ application and test containers still connect directly to `db` on its internal
149
+ port, without TLS or a host-published port. A project not using the gateway must
150
+ select its own explicit port-publishing fallback.
151
+
146
152
  All `.localhost` names resolve to loopback. The gateway publishes loopback-only
147
153
  listeners, discovers labelled containers through Docker, and uses the database
148
154
  protocol's encrypted handshake to select the hostname-labelled container.
149
155
  Container replacements and stopped routes require no consumer lifecycle code
150
156
  or cleanup.
151
157
 
152
- SQL Server encryption remains end to end through the router. The PostgreSQL
153
- driver terminates client TLS at the gateway and forwards the resulting
154
- PostgreSQL stream over the private Docker network because PostgreSQL sends its
155
- TLS hostname only after the gateway accepts its SSL request. See
158
+ SQL Server encryption remains end to end through the router. PostgreSQL TLS
159
+ terminates at the gateway; the backend stream is plaintext on the private Docker
160
+ network. New PostgreSQL sessions require TLS and SNI, but query cancellation
161
+ does not: the gateway substitutes a session-specific cancellation identity and
162
+ routes ordinary `psql` and JDBC cancellation packets to that session's backend.
163
+ Closed sessions lose their cancellation mapping. See
156
164
  [`docs/architecture/local-tcp-routing.md`](docs/architecture/local-tcp-routing.md)
157
- for the complete contract and security boundary.
165
+ for the complete contract and security boundary, including the reason for
166
+ retaining cancellation-aware PostgreSQL routing despite Traefik's STARTTLS support.
158
167
 
159
168
  ## Development checks
160
169
 
@@ -169,6 +178,7 @@ bundle exec ruby "$(bundle show syntax_tree)/exe/stree" check \
169
178
  lib/local_development_gateway/database_router.rb \
170
179
  lib/local_development_gateway/database_router/*.rb \
171
180
  lib/local_development_gateway/database_router/drivers/*.rb \
181
+ lib/local_development_gateway/database_router/postgre_sql/*.rb \
172
182
  lib/local_development_gateway/database_router/tds/*.rb \
173
183
  lib/local_development_gateway/version.rb \
174
184
  bin/dev \
@@ -176,6 +186,7 @@ bundle exec ruby "$(bundle show syntax_tree)/exe/stree" check \
176
186
  test/*.rb \
177
187
  test/database_router/*.rb \
178
188
  test/database_router/drivers/*.rb \
189
+ test/database_router/postgre_sql/*.rb \
179
190
  test/database_router/tds/*.rb
180
191
  rake test
181
192
  ```
@@ -251,3 +262,16 @@ It mounts the Docker socket so it can discover labelled containers. A read-only
251
262
  | Generated hostname returns `404` | Confirm the web container has the route labels and is attached to `local-gateway`. |
252
263
  | A project uses normal ports unexpectedly | Confirm the gateway container is running and has `local-gateway=true`. |
253
264
  | Authentication rejects the callback | Confirm the generated hostname matches the allowed wildcard callback pattern. |
265
+
266
+ ## Release 0.2.1
267
+
268
+ - PostgreSQL query cancellation works across concurrent worktrees, including
269
+ plaintext cancellation from `psql` and JDBC and encrypted cancellation.
270
+ Session-specific identities prevent backend key collisions and expire when
271
+ their session closes.
272
+ - Invalid database labels and duplicate hostname identities reject only the
273
+ affected routes, leaving unrelated worktrees and database drivers available.
274
+ - SQL Server negotiation can fall back from a stalled backend within the
275
+ shared handshake deadline.
276
+ - PostgreSQL STARTTLS support in Traefik is documented accurately. The gateway
277
+ retains cancellation-aware session routing without changing consumer labels.
@@ -18,18 +18,35 @@ module LocalDevelopmentGateway
18
18
  routes =
19
19
  @client
20
20
  .get("/containers/json")
21
- .filter_map { |container| route(container) }
22
- identities = routes.map { |route| [route.driver, route.hostname] }
23
- unless identities.uniq.length == identities.length
24
- raise Error, "Duplicate labelled database hostname"
21
+ .filter_map do |container|
22
+ begin
23
+ route(container)
24
+ rescue Error => error
25
+ warn error.message
26
+ nil
27
+ end
28
+ end
29
+ identities = routes.group_by { |route| [route.driver, route.hostname] }
30
+ identities.each do |identity, matches|
31
+ next if matches.length == 1
32
+
33
+ warn "Duplicate labelled database hostname: #{identity.join(" ")}"
25
34
  end
26
35
 
27
- routes.sort_by { |route| [route.driver, route.hostname] }
36
+ routes
37
+ .reject do |route|
38
+ identities.fetch([route.driver, route.hostname]).length > 1
39
+ end
40
+ .sort_by { |route| [route.driver, route.hostname] }
28
41
  end
29
42
 
30
43
  private
31
44
 
32
45
  def route(container)
46
+ target_address =
47
+ container.dig("NetworkSettings", "Networks", NETWORK_NAME, "IPAddress")
48
+ return if target_address.nil? || target_address.empty?
49
+
33
50
  labels = container.fetch("Labels")
34
51
  driver = labels[DRIVER_LABEL]
35
52
  hostname = labels[HOSTNAME_LABEL]
@@ -52,16 +69,14 @@ module LocalDevelopmentGateway
52
69
  raise Error, "Invalid labelled database port: #{port}"
53
70
  end
54
71
 
55
- target_address =
56
- container.dig("NetworkSettings", "Networks", NETWORK_NAME, "IPAddress")
57
- return if target_address.nil? || target_address.empty?
58
-
59
72
  DatabaseRouter::Route.new(
60
73
  driver: driver,
61
74
  hostname: hostname,
62
75
  port: port,
63
76
  target_address: target_address
64
77
  )
78
+ rescue KeyError
79
+ raise Error, "Incomplete labelled database route"
65
80
  rescue ArgumentError, TypeError
66
81
  raise Error, "Invalid labelled database port: #{port}"
67
82
  end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "openssl"
4
+
4
5
  require "local_development_gateway/database_router/wire"
5
6
  require "local_development_gateway/database_router/drivers/postgre_sql_certificate"
7
+ require "local_development_gateway/database_router/postgre_sql/session"
6
8
 
7
9
  module LocalDevelopmentGateway
8
10
  module DatabaseRouter::Drivers
@@ -10,9 +12,14 @@ module LocalDevelopmentGateway
10
12
  NAME = "postgresql"
11
13
  LISTEN_PORT = 5432
12
14
  SSL_REQUEST = [8, 80_877_103].pack("NN").freeze
15
+ CANCEL_REQUEST = [16, 80_877_102].pack("NN").freeze
13
16
 
14
- def initialize(certificate: PostgreSqlCertificate.new)
17
+ def initialize(
18
+ certificate: PostgreSqlCertificate.new,
19
+ cancellations: DatabaseRouter::PostgreSql::CancellationRoutes.new
20
+ )
15
21
  @certificate = certificate
22
+ @cancellations = cancellations
16
23
  end
17
24
 
18
25
  def name
@@ -23,18 +30,15 @@ module LocalDevelopmentGateway
23
30
  LISTEN_PORT
24
31
  end
25
32
 
26
- def connect(client, routes, connector:)
33
+ def connect(client, routes:, connector:)
27
34
  deadline = DatabaseRouter::Wire.deadline
28
35
  request =
29
- DatabaseRouter::Wire.read_exactly(
30
- client,
31
- SSL_REQUEST.bytesize,
32
- deadline: deadline
33
- )
34
- raise Error, "PostgreSQL SSL is required" unless request == SSL_REQUEST
35
- if routes.empty?
36
- raise Error, "No labelled postgresql routes are available"
36
+ DatabaseRouter::Wire.read_exactly(client, 8, deadline: deadline)
37
+ if cancel_request?(request)
38
+ cancel(client, connector, deadline)
39
+ return
37
40
  end
41
+ raise Error, "PostgreSQL SSL is required" unless request == SSL_REQUEST
38
42
 
39
43
  client.write("S")
40
44
  hostname = nil
@@ -44,16 +48,78 @@ module LocalDevelopmentGateway
44
48
  nil
45
49
  end
46
50
  tls = OpenSSL::SSL::SSLSocket.new(client, context)
47
- tls.sync_close = false
51
+ tls.sync_close = true
48
52
  DatabaseRouter::Wire.accept_tls(tls, deadline: deadline)
53
+
54
+ startup = DatabaseRouter::Wire.read_exactly(tls, 8, deadline: deadline)
55
+ if cancel_request?(startup)
56
+ cancel(tls, connector, deadline)
57
+ tls.close
58
+ return
59
+ end
60
+
61
+ length, version = startup.unpack("NN")
62
+ unless length >= 9 && (version >> 16) == 3
63
+ raise Error, "Unsupported PostgreSQL startup request"
64
+ end
65
+
49
66
  unless hostname
50
67
  raise Error, "PostgreSQL TLS ClientHello does not contain SNI"
51
68
  end
52
69
 
53
- selected = routes.find { |route| route.hostname == hostname }
70
+ selected = routes.call.find { |route| route.hostname == hostname }
54
71
  raise Error, "No PostgreSQL route for #{hostname}" unless selected
55
72
 
56
- [tls, connector.call(selected)]
73
+ target = connector.call(selected, deadline: deadline)
74
+ target.write(startup)
75
+ DatabaseRouter::Connection.new(
76
+ source: tls,
77
+ target: target,
78
+ route: selected
79
+ )
80
+ rescue StandardError
81
+ tls&.close
82
+ target&.close
83
+ raise
84
+ end
85
+
86
+ def forward(connection)
87
+ session = DatabaseRouter::PostgreSql::Session.new(@cancellations)
88
+ DatabaseRouter::Wire.proxy(
89
+ connection.source,
90
+ connection.target
91
+ ) do |backend, client|
92
+ session.forward_response(backend, client, connection.route)
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def cancel_request?(header)
99
+ return false unless header.byteslice(4, 4).unpack1("N") == 80_877_102
100
+ unless header == CANCEL_REQUEST
101
+ raise Error, "Invalid PostgreSQL cancellation length"
102
+ end
103
+
104
+ true
105
+ end
106
+
107
+ def cancel(client, connector, deadline)
108
+ identity =
109
+ DatabaseRouter::Wire.read_exactly(client, 8, deadline: deadline)
110
+ destination = @cancellations.resolve(identity)
111
+ return unless destination
112
+
113
+ target = connector.call(destination.route, deadline: deadline)
114
+ packet = [destination.backend_key.bytesize + 8, 80_877_102].pack("NN")
115
+ target.write(packet + destination.backend_key)
116
+ DatabaseRouter::Wire.read_until_eof(
117
+ target,
118
+ max_bytes: 0,
119
+ deadline: deadline
120
+ )
121
+ ensure
122
+ target&.close
57
123
  end
58
124
  end
59
125
  end
@@ -6,6 +6,8 @@ require "local_development_gateway/database_router/tds/tls_client_hello"
6
6
  module LocalDevelopmentGateway
7
7
  module DatabaseRouter::Drivers
8
8
  class SqlServerDriver
9
+ NEGOTIATION_ATTEMPT_TIMEOUT = 1
10
+
9
11
  NAME = "sql_server"
10
12
  LISTEN_PORT = 1433
11
13
 
@@ -17,9 +19,10 @@ module LocalDevelopmentGateway
17
19
  LISTEN_PORT
18
20
  end
19
21
 
20
- def connect(client, routes, connector:)
22
+ def connect(client, routes:, connector:)
21
23
  deadline = DatabaseRouter::Wire.deadline
22
24
  prelogin = DatabaseRouter::Tds::Message.read(client, deadline: deadline)
25
+ routes = routes.call
23
26
  if routes.empty?
24
27
  raise Error, "No labelled sql_server routes are available"
25
28
  end
@@ -33,7 +36,7 @@ module LocalDevelopmentGateway
33
36
 
34
37
  if selected != provisional
35
38
  target.close
36
- target = connector.call(selected)
39
+ target = connector.call(selected, deadline: deadline)
37
40
  DatabaseRouter::Tds::Message.write(target, prelogin)
38
41
  DatabaseRouter::Tds::Message.read(target, deadline: deadline)
39
42
  end
@@ -41,22 +44,41 @@ module LocalDevelopmentGateway
41
44
  messages.each do |message|
42
45
  DatabaseRouter::Tds::Message.write(target, message)
43
46
  end
44
- [client, target]
47
+ DatabaseRouter::Connection.new(
48
+ source: client,
49
+ target: target,
50
+ route: selected
51
+ )
45
52
  rescue StandardError
46
53
  target&.close
47
54
  raise
48
55
  end
49
56
 
57
+ def forward(connection)
58
+ DatabaseRouter::Wire.proxy(connection.source, connection.target)
59
+ end
60
+
50
61
  private
51
62
 
52
63
  def negotiate(routes, prelogin, connector, deadline)
53
64
  routes.each do |route|
54
- target = connector.call(route)
65
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ break unless remaining.positive?
67
+
68
+ attempt_deadline = [
69
+ deadline,
70
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) +
71
+ NEGOTIATION_ATTEMPT_TIMEOUT
72
+ ].min
73
+ target = connector.call(route, deadline: attempt_deadline)
55
74
  DatabaseRouter::Tds::Message.write(target, prelogin)
56
75
  return [
57
76
  route,
58
77
  target,
59
- DatabaseRouter::Tds::Message.read(target, deadline: deadline)
78
+ DatabaseRouter::Tds::Message.read(
79
+ target,
80
+ deadline: attempt_deadline
81
+ )
60
82
  ]
61
83
  rescue Error, EOFError, IOError, SystemCallError
62
84
  target&.close
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module LocalDevelopmentGateway
6
+ module DatabaseRouter::PostgreSql
7
+ class CancellationRoutes
8
+ Destination = Data.define(:route, :backend_key)
9
+
10
+ def initialize
11
+ @destinations = {}
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def register(route, backend_key)
16
+ @mutex.synchronize do
17
+ identity = new_identity
18
+ identity = new_identity while @destinations.key?(identity)
19
+
20
+ identity.freeze
21
+ @destinations[identity] = Destination.new(
22
+ route: route,
23
+ backend_key: backend_key.dup.freeze
24
+ )
25
+ identity
26
+ end
27
+ end
28
+
29
+ def resolve(identity)
30
+ @mutex.synchronize { @destinations[identity] }
31
+ end
32
+
33
+ def remove(identity)
34
+ @mutex.synchronize { @destinations.delete(identity) }
35
+ end
36
+
37
+ private
38
+
39
+ def new_identity
40
+ identity = SecureRandom.random_bytes(8)
41
+ identity.setbyte(0, identity.getbyte(0) & 0x7f)
42
+ identity.setbyte(3, 1) if identity.unpack1("N").zero?
43
+ identity
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "local_development_gateway/database_router/postgre_sql/cancellation_routes"
4
+
5
+ module LocalDevelopmentGateway
6
+ class DatabaseRouter::PostgreSql::Session
7
+ MAX_BACKEND_KEY_BYTES = 260
8
+
9
+ def initialize(cancellations)
10
+ @cancellations = cancellations
11
+ end
12
+
13
+ def forward_response(backend, client, route)
14
+ identity = nil
15
+ loop do
16
+ header = backend.read(5)
17
+ raise EOFError unless header&.bytesize == 5
18
+
19
+ payload_length = header.byteslice(1, 4).unpack1("N") - 4
20
+ if payload_length.negative?
21
+ raise Error, "Invalid PostgreSQL message length"
22
+ end
23
+
24
+ if header.getbyte(0) == "K".ord
25
+ unless (8..MAX_BACKEND_KEY_BYTES).cover?(payload_length)
26
+ raise Error, "Invalid PostgreSQL backend key length"
27
+ end
28
+
29
+ backend_key = backend.read(payload_length)
30
+ raise EOFError unless backend_key&.bytesize == payload_length
31
+
32
+ identity = @cancellations.register(route, backend_key)
33
+ client.write("K" + [12].pack("N") + identity)
34
+ IO.copy_stream(backend, client)
35
+ return
36
+ end
37
+
38
+ client.write(header)
39
+ copied = IO.copy_stream(backend, client, payload_length)
40
+ raise EOFError unless copied == payload_length
41
+ end
42
+ ensure
43
+ @cancellations.remove(identity) if identity
44
+ end
45
+ end
46
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "openssl"
4
+ require "socket"
5
+
3
6
  module LocalDevelopmentGateway
4
7
  module DatabaseRouter::Wire
5
8
  HANDSHAKE_TIMEOUT = 5
@@ -13,12 +16,70 @@ module LocalDevelopmentGateway
13
16
  def read_exactly(io, length, deadline:)
14
17
  bytes = +""
15
18
  while bytes.bytesize < length
16
- wait(io, readable: true, deadline: deadline)
17
- bytes << io.readpartial(length - bytes.bytesize)
19
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ raise Error, "Database handshake timed out" unless remaining.positive?
21
+
22
+ chunk = io.read_nonblock(length - bytes.bytesize, exception: false)
23
+ case chunk
24
+ when :wait_readable
25
+ wait(io, readable: true, deadline: deadline)
26
+ when :wait_writable
27
+ wait(io, readable: false, deadline: deadline)
28
+ when nil
29
+ raise EOFError
30
+ else
31
+ bytes << chunk
32
+ end
18
33
  end
19
34
  bytes
20
35
  end
21
36
 
37
+ def proxy(client, target, &response)
38
+ upstream =
39
+ Thread.new do
40
+ IO.copy_stream(client, target)
41
+ rescue IOError, EOFError, OpenSSL::SSL::SSLError, SystemCallError
42
+ nil
43
+ ensure
44
+ close_write(target)
45
+ end
46
+ downstream =
47
+ Thread.new do
48
+ if response
49
+ response.call(target, client)
50
+ else
51
+ IO.copy_stream(target, client)
52
+ end
53
+ rescue IOError, EOFError, OpenSSL::SSL::SSLError, SystemCallError
54
+ nil
55
+ ensure
56
+ close_connection(client)
57
+ close_connection(target)
58
+ end
59
+ threads = [upstream, downstream]
60
+ threads.each { |thread| thread.report_on_exception = false }
61
+ threads.each(&:join)
62
+ ensure
63
+ close_connection(client)
64
+ close_connection(target)
65
+ threads&.each(&:join)
66
+ end
67
+
68
+ def close_write(io)
69
+ io.close_write unless io.closed?
70
+ rescue IOError, SystemCallError
71
+ nil
72
+ end
73
+
74
+ def close_connection(io)
75
+ socket = io.to_io
76
+ socket.shutdown(Socket::SHUT_RDWR) unless socket.closed?
77
+ rescue IOError, SystemCallError
78
+ nil
79
+ ensure
80
+ io.close unless io.closed?
81
+ end
82
+
22
83
  def read_until_eof(io, max_bytes:, deadline:)
23
84
  bytes = +""
24
85
  loop do
@@ -7,7 +7,9 @@ module LocalDevelopmentGateway
7
7
  class DatabaseRouter
8
8
  CONNECT_TIMEOUT = 3
9
9
  MAX_CONNECTIONS = 128
10
+ MAX_HANDSHAKES = 128
10
11
  Route = Data.define(:driver, :hostname, :port, :target_address)
12
+ Connection = Data.define(:source, :target, :route)
11
13
 
12
14
  def self.run
13
15
  new.run
@@ -23,10 +25,13 @@ module LocalDevelopmentGateway
23
25
  def initialize(
24
26
  routes: DockerRoutes.new,
25
27
  drivers: self.class.drivers,
26
- servers: nil
28
+ servers: nil,
29
+ max_connections: MAX_CONNECTIONS
27
30
  )
28
31
  @routes = routes
29
32
  @drivers = drivers
33
+ @session_slots = SizedQueue.new(max_connections)
34
+ max_connections.times { @session_slots << true }
30
35
  @servers =
31
36
  servers ||
32
37
  drivers.to_h do |driver|
@@ -35,8 +40,8 @@ module LocalDevelopmentGateway
35
40
  end
36
41
 
37
42
  def run
38
- slots = SizedQueue.new(MAX_CONNECTIONS)
39
- MAX_CONNECTIONS.times { slots << true }
43
+ slots = SizedQueue.new(MAX_HANDSHAKES)
44
+ MAX_HANDSHAKES.times { slots << true }
40
45
  @drivers
41
46
  .map do |driver|
42
47
  Thread.new do
@@ -45,9 +50,7 @@ module LocalDevelopmentGateway
45
50
  client = server.accept
46
51
  slots.pop
47
52
  Thread.new(client) do |connection|
48
- route(connection, driver)
49
- ensure
50
- slots << true
53
+ route(connection, driver, handshake_slots: slots)
51
54
  end
52
55
  end
53
56
  end
@@ -55,48 +58,46 @@ module LocalDevelopmentGateway
55
58
  .each(&:join)
56
59
  end
57
60
 
58
- def route(client, driver)
59
- routes = @routes.call.select { |route| route.driver == driver.name }
61
+ def route(client, driver, handshake_slots: nil)
62
+ routes = -> do
63
+ @routes.call.select { |route| route.driver == driver.name }
64
+ end
65
+ connection =
66
+ driver.connect(client, routes: routes, connector: method(:connect))
67
+ handshake_slots&.push(true)
68
+ handshake_slots = nil
69
+ return unless connection
60
70
 
61
- source, target =
62
- driver.connect(client, routes, connector: method(:connect))
63
- proxy(source, target)
71
+ session_slot = @session_slots.pop(true)
72
+ driver.forward(connection)
64
73
  rescue EOFError
65
74
  nil
75
+ rescue ThreadError
76
+ warn "Database session limit reached"
66
77
  rescue Error => error
67
78
  warn error.message
68
79
  rescue StandardError => error
69
80
  warn error.full_message
70
81
  ensure
71
- source&.close unless source.equal?(client)
82
+ handshake_slots&.push(true)
83
+ @session_slots << true if session_slot
84
+ connection&.source&.close
72
85
  client&.close
73
- target&.close
86
+ connection&.target&.close
74
87
  end
75
88
 
76
89
  private
77
90
 
78
- def connect(route)
91
+ def connect(route, deadline:)
92
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
93
+ raise Error, "Database handshake timed out" unless remaining.positive?
94
+
79
95
  Socket.tcp(
80
96
  route.target_address,
81
97
  route.port,
82
- connect_timeout: CONNECT_TIMEOUT
98
+ connect_timeout: [CONNECT_TIMEOUT, remaining].min
83
99
  )
84
100
  end
85
-
86
- def proxy(client, target)
87
- [[client, target], [target, client]].map do |source, destination|
88
- Thread.new do
89
- IO.copy_stream(source, destination)
90
- rescue IOError, OpenSSL::SSL::SSLError, SystemCallError
91
- nil
92
- ensure
93
- if destination.respond_to?(:close_write) && !destination.closed?
94
- destination.close_write
95
- end
96
- end
97
- end
98
- .each(&:join)
99
- end
100
101
  end
101
102
  end
102
103
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LocalDevelopmentGateway
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: local-development-gateway
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marlen Brunner
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-14 00:00:00.000000000 Z
11
+ date: 2026-10-08 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Ruby API and CLI for starting, monitoring, and conditionally stopping
14
14
  the shared Docker gateway.
@@ -30,6 +30,8 @@ files:
30
30
  - lib/local_development_gateway/database_router/drivers/postgre_sql_certificate.rb
31
31
  - lib/local_development_gateway/database_router/drivers/postgre_sql_driver.rb
32
32
  - lib/local_development_gateway/database_router/drivers/sql_server_driver.rb
33
+ - lib/local_development_gateway/database_router/postgre_sql/cancellation_routes.rb
34
+ - lib/local_development_gateway/database_router/postgre_sql/session.rb
33
35
  - lib/local_development_gateway/database_router/tds/message.rb
34
36
  - lib/local_development_gateway/database_router/tds/tls_byte_reader.rb
35
37
  - lib/local_development_gateway/database_router/tds/tls_client_hello.rb