hotswap 0.2.3 → 0.3.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: 9fb57abbaa8c101d3ec89ba7beb71a1c363fef0dc8504169f3d346851d3df277
4
- data.tar.gz: cd24097f831156becc966d22fba0aba7db145d2d5eb40b2b90f49048b9090dd2
3
+ metadata.gz: ad93b96b0ced98527e4b48c2df94758034ea339c08cf0ef5b9aaeb60f62cd1cc
4
+ data.tar.gz: de80e2a8e41794142c2785d44dbeaf21114888a4e85dcf3009af4118d7ba64ee
5
5
  SHA512:
6
- metadata.gz: 1f195e3c1335758d3b6cd69c4f51b34edeb82c1f787b8331909460b2716f0ee72bdd569d740d5c5a61d4c12c60b97432346de97a4fe5b453c2251f9cd246fa7f
7
- data.tar.gz: 1588206ad76daf621e41c7f98c5f5c8545fde3e5f8fe7cfdd2c295bfba84787d34e56e6ba1d0e5a141a9ac9b1dacd609210e8c4870ff37558bb970f9d02fb5ee
6
+ metadata.gz: 2a45194ce3883a74d3e5f5bfe12ef5da08a0d301eb2a5fa507a8fc8c94c79611fbddccdcb287febfc7cc6486ce10a7e260f4213a15f6d1d6119f4597dea90fc4
7
+ data.tar.gz: 819ae0bab10da130c146ccc863161cdf8c36964731bafe37efc95a9f1c27b0b7bbf307254397f08b45367ce07ceceb8e88f1339ba9a953b2c6ccaa940e49ebf8
data/exe/hotswap CHANGED
@@ -1,51 +1,80 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  require "socket"
4
- require "shellwords"
4
+ require "json"
5
+
6
+ # Inline protocol encode/decode so the client has zero gem dependencies
7
+ module ThorSocketProtocol
8
+ HEADER_SIZE = 5
9
+
10
+ def self.encode(channel, data)
11
+ data = data.b
12
+ [channel, data.bytesize].pack("CN") + data
13
+ end
14
+
15
+ def self.decode(io)
16
+ header = io.read(HEADER_SIZE)
17
+ return nil if header.nil? || header.bytesize < HEADER_SIZE
18
+ channel, length = header.unpack("CN")
19
+ payload = io.read(length)
20
+ return nil if payload.nil? || payload.bytesize < length
21
+ [channel, payload]
22
+ end
23
+ end
24
+
25
+ STDIN_CH = 0
26
+ STDOUT_CH = 1
27
+ STDERR_CH = 2
28
+ CTRL_CH = 3
5
29
 
6
30
  socket_path = ENV.fetch("HOTSWAP_SOCKET", "tmp/sockets/hotswap.sock")
7
- stderr_socket_path = ENV.fetch("HOTSWAP_STDERR_SOCKET", "tmp/sockets/hotswap.stderr.sock")
8
31
 
9
32
  unless File.exist?(socket_path)
10
33
  $stderr.puts "ERROR: socket not found at #{socket_path}"
11
34
  exit 1
12
35
  end
13
36
 
14
- # Connect stderr socket first so it's ready before the command runs
15
- stderr_sock = nil
16
- if File.exist?(stderr_socket_path)
17
- stderr_sock = UNIXSocket.new(stderr_socket_path)
18
- end
19
-
20
37
  sock = UNIXSocket.new(socket_path)
21
38
 
22
- # Shell-escape args to preserve spaces in file paths
23
- args = ARGV.map { |a| Shellwords.escape(a) }.join(" ")
24
- sock.write(args + "\n")
39
+ # Send control frame with args and tty status
40
+ control = { "args" => ARGV, "tty" => $stdout.tty? }
41
+ sock.write(ThorSocketProtocol.encode(CTRL_CH, JSON.generate(control)))
25
42
 
26
- # Pipe stdin to socket (only if data is being piped in)
43
+ # Pipe stdin if '-' is an argument and stdin isn't a tty
27
44
  writer = nil
28
- if !$stdin.tty?
45
+ if ARGV.include?("-") && !$stdin.tty?
29
46
  writer = Thread.new do
30
- IO.copy_stream($stdin, sock) rescue nil
31
- sock.close_write rescue nil
47
+ begin
48
+ buf = String.new(capacity: 16384)
49
+ while $stdin.read(16384, buf)
50
+ sock.write(ThorSocketProtocol.encode(STDIN_CH, buf))
51
+ end
52
+ rescue IOError, Errno::EPIPE
53
+ ensure
54
+ sock.write(ThorSocketProtocol.encode(STDIN_CH, "")) rescue nil
55
+ end
32
56
  end
33
- else
34
- sock.close_write
35
57
  end
36
58
 
37
- # Pipe stderr socket to local stderr (in background thread)
38
- stderr_reader = nil
39
- if stderr_sock
40
- stderr_reader = Thread.new do
41
- IO.copy_stream(stderr_sock, $stderr) rescue nil
59
+ # Read frames from server
60
+ exit_code = 0
61
+ loop do
62
+ result = ThorSocketProtocol.decode(sock)
63
+ break unless result
64
+
65
+ channel, data = result
66
+ case channel
67
+ when STDOUT_CH
68
+ $stdout.write(data)
69
+ when STDERR_CH
70
+ $stderr.write(data)
71
+ when CTRL_CH
72
+ ctrl = JSON.parse(data)
73
+ exit_code = ctrl.fetch("exit", 0)
74
+ break
42
75
  end
43
76
  end
44
77
 
45
- # Pipe socket output to stdout
46
- IO.copy_stream(sock, $stdout) rescue nil
47
-
48
78
  writer&.join
49
- stderr_reader&.join
50
- stderr_sock&.close rescue nil
51
79
  sock.close rescue nil
80
+ exit exit_code
data/lib/hotswap/cli.rb CHANGED
@@ -6,30 +6,16 @@ module Hotswap
6
6
  false
7
7
  end
8
8
 
9
- class Shell < Thor::Shell::Basic
10
- def initialize(stdout, stderr)
11
- super()
12
- @_stdout = stdout
13
- @_stderr = stderr
14
- end
15
-
16
- def stdout = @_stdout
17
- def stderr = @_stderr
18
- end
19
-
20
9
  def self.run(args, stdin: $stdin, stdout: $stdout, stderr: $stderr)
21
10
  Thread.current[:hotswap_stdin] = stdin
22
- Thread.current[:hotswap_stdout] = stdout
23
- Thread.current[:hotswap_stderr] = stderr
24
11
 
25
12
  args = ["help"] if args.empty?
26
- start(args, shell: Shell.new(stdout, stderr))
13
+ shell = Thor::Socket::Shell.new(stdout, stderr)
14
+ start(args, shell: shell)
27
15
  rescue SystemExit
28
16
  # Thor calls exit on errors — catch it so we don't kill the server
29
17
  ensure
30
18
  Thread.current[:hotswap_stdin] = nil
31
- Thread.current[:hotswap_stdout] = nil
32
- Thread.current[:hotswap_stderr] = nil
33
19
  end
34
20
 
35
21
  desc "cp SRC DST", "Copy a database to/from the running server"
@@ -46,37 +32,39 @@ module Hotswap
46
32
  hotswap cp - db/production.sqlite3 # push from stdin
47
33
  hotswap cp db/production.sqlite3 - # pull to stdout
48
34
  DESC
35
+ method_option :skip_integrity_check, type: :boolean, default: false, desc: "Skip SQLite integrity check on push"
36
+ method_option :skip_schema_check, type: :boolean, default: false, desc: "Skip schema compatibility check on push"
49
37
  def cp(src, dst)
50
38
  src_db = resolve_database(src)
51
39
  dst_db = resolve_database(dst)
52
40
 
53
41
  if src_db && dst_db
54
- io_err.write("ERROR: source and destination can't both be managed databases\n")
42
+ shell.stderr.write("ERROR: source and destination can't both be managed databases\n")
55
43
  return
56
44
  end
57
45
 
58
46
  if dst_db
59
47
  source = (src == "-") ? io_in : src
60
- dst_db.push(source, stdout: io_out, stderr: io_err)
48
+ dst_db.push(source, stdout: shell.stdout, stderr: shell.stderr,
49
+ skip_integrity_check: options[:skip_integrity_check],
50
+ skip_schema_check: options[:skip_schema_check])
61
51
  elsif src_db
62
- destination = (dst == "-") ? io_out : dst
63
- src_db.pull(destination, stderr: io_err)
52
+ destination = (dst == "-") ? shell.stdout : dst
53
+ src_db.pull(destination, stderr: shell.stderr)
64
54
  else
65
55
  paths = Hotswap.databases.map(&:path).join(", ")
66
- io_err.write("ERROR: neither path matches a managed database (#{paths})\n")
56
+ shell.stderr.write("ERROR: neither path matches a managed database (#{paths})\n")
67
57
  end
68
58
  end
69
59
 
70
- desc "version", "Print the hotswap version"
60
+ desc "version", "Print the hotswap version"
71
61
  def version
72
- io_out.write("hotswap #{Hotswap::VERSION}\n")
62
+ shell.stdout.write("hotswap #{Hotswap::VERSION}\n")
73
63
  end
74
64
 
75
65
  private
76
66
 
77
- def io_in = Thread.current[:hotswap_stdin] || $stdin
78
- def io_out = Thread.current[:hotswap_stdout] || $stdout
79
- def io_err = Thread.current[:hotswap_stderr] || $stderr
67
+ def io_in = Thread.current[:hotswap_stdin] || $stdin
80
68
 
81
69
  def resolve_database(path)
82
70
  return nil if path == "-"
@@ -11,7 +11,7 @@ module Hotswap
11
11
  end
12
12
 
13
13
  # Push a new database from an IO stream or file path
14
- def push(source, stdout: $stdout, stderr: $stderr)
14
+ def push(source, stdout: $stdout, stderr: $stderr, skip_integrity_check: false, skip_schema_check: false)
15
15
  source_label = source.is_a?(String) ? source : "stdin"
16
16
  logger.info "push started: #{source_label} → #{@path}"
17
17
 
@@ -22,18 +22,42 @@ module Hotswap
22
22
  begin
23
23
  IO.copy_stream(input, temp)
24
24
  temp.close
25
- logger.info "received #{File.size(temp.path)} bytes, running integrity check"
26
-
27
- db = SQLite3::Database.new(temp.path)
28
- result = db.execute("PRAGMA integrity_check")
29
- db.close
30
- unless result == [["ok"]]
31
- logger.error "integrity check failed for #{source_label}"
32
- stderr.write("ERROR: integrity check failed\n")
33
- return false
25
+ logger.info "received #{File.size(temp.path)} bytes"
26
+
27
+ unless skip_integrity_check
28
+ logger.info "running integrity check"
29
+ db = SQLite3::Database.new(temp.path)
30
+ result = db.execute("PRAGMA integrity_check")
31
+ db.close
32
+ unless result == [["ok"]]
33
+ logger.error "integrity check failed for #{source_label}"
34
+ stderr.write("ERROR: integrity check failed\n")
35
+ return false
36
+ end
37
+ logger.info "integrity check passed"
38
+ end
39
+
40
+ unless skip_schema_check
41
+ logger.info "running schema check"
42
+ new_db = SQLite3::Database.new(temp.path)
43
+ cur_db = SQLite3::Database.new(@path)
44
+ new_schema = new_db.execute("SELECT sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type, name").flatten
45
+ cur_schema = cur_db.execute("SELECT sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type, name").flatten
46
+ new_db.close
47
+ cur_db.close
48
+
49
+ if new_schema != cur_schema
50
+ logger.error "schema mismatch for #{source_label}"
51
+ diff_lines = []
52
+ (cur_schema - new_schema).each { |s| diff_lines << "- #{s}" }
53
+ (new_schema - cur_schema).each { |s| diff_lines << "+ #{s}" }
54
+ stderr.write("ERROR: schema mismatch\n#{diff_lines.join("\n")}\n")
55
+ return false
56
+ end
57
+ logger.info "schema check passed"
34
58
  end
35
59
 
36
- logger.info "integrity check passed, acquiring swap lock"
60
+ logger.info "acquiring swap lock"
37
61
  stderr.write("Swapping database...\n")
38
62
 
39
63
  Middleware::SWAP_LOCK.synchronize do
@@ -21,18 +21,12 @@ module Hotswap
21
21
  end
22
22
  end
23
23
 
24
- # Socket paths
24
+ # Socket path
25
25
  if app.config.hotswap.socket_path
26
26
  Hotswap.socket_path = app.config.hotswap.socket_path
27
27
  else
28
28
  Hotswap.socket_path = File.join(app.root, "tmp", "sockets", "hotswap.sock")
29
29
  end
30
-
31
- if app.config.hotswap.stderr_socket_path
32
- Hotswap.stderr_socket_path = app.config.hotswap.stderr_socket_path
33
- else
34
- Hotswap.stderr_socket_path = File.join(app.root, "tmp", "sockets", "hotswap.stderr.sock")
35
- end
36
30
  end
37
31
 
38
32
  initializer "hotswap.middleware" do |app|
@@ -40,9 +34,15 @@ module Hotswap
40
34
  end
41
35
 
42
36
  server do
43
- server = Hotswap::SocketServer.new
37
+ server = Thor::Socket::Server.new(
38
+ Hotswap::CLI,
39
+ socket_path: Hotswap.socket_path,
40
+ logger: Hotswap.logger
41
+ )
44
42
  server.start
45
43
 
44
+ Hotswap.logger.info "managing #{Hotswap.databases.size} database(s): #{Hotswap.databases.map(&:path).join(', ')}"
45
+
46
46
  at_exit { server.stop }
47
47
  end
48
48
  end
@@ -1,3 +1,3 @@
1
1
  module Hotswap
2
- VERSION = "0.2.3"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/hotswap.rb CHANGED
@@ -2,15 +2,15 @@ require "logger"
2
2
  require_relative "hotswap/version"
3
3
  require_relative "hotswap/middleware"
4
4
  require_relative "hotswap/database"
5
+ require_relative "thor/socket"
5
6
  require_relative "hotswap/cli"
6
- require_relative "hotswap/socket_server"
7
7
  require_relative "hotswap/railtie" if defined?(Rails::Railtie)
8
8
 
9
9
  module Hotswap
10
10
  class Error < StandardError; end
11
11
 
12
12
  class << self
13
- attr_accessor :socket_path, :stderr_socket_path
13
+ attr_accessor :socket_path
14
14
  attr_writer :logger
15
15
 
16
16
  def logger
@@ -50,5 +50,4 @@ module Hotswap
50
50
  end
51
51
 
52
52
  self.socket_path = "tmp/sockets/hotswap.sock"
53
- self.stderr_socket_path = "tmp/sockets/hotswap.stderr.sock"
54
53
  end
@@ -0,0 +1,353 @@
1
+ require "socket"
2
+ require "json"
3
+ require "thor"
4
+
5
+ class Thor
6
+ module Socket
7
+ module Protocol
8
+ CHANNEL_STDIN = 0
9
+ CHANNEL_STDOUT = 1
10
+ CHANNEL_STDERR = 2
11
+ CHANNEL_CONTROL = 3
12
+
13
+ HEADER_SIZE = 5 # 1 byte channel + 4 bytes length
14
+
15
+ def self.encode(channel, data)
16
+ data = data.b
17
+ [channel, data.bytesize].pack("CN") + data
18
+ end
19
+
20
+ def self.decode(io)
21
+ header = io.read(HEADER_SIZE)
22
+ return nil if header.nil? || header.bytesize < HEADER_SIZE
23
+
24
+ channel, length = header.unpack("CN")
25
+ payload = io.read(length)
26
+ return nil if payload.nil? || payload.bytesize < length
27
+
28
+ [channel, payload]
29
+ end
30
+ end
31
+
32
+ class FramedWriter
33
+ def initialize(io, channel)
34
+ @io = io
35
+ @channel = channel
36
+ @mutex = Mutex.new
37
+ end
38
+
39
+ def write(data)
40
+ data = data.to_s
41
+ return 0 if data.empty?
42
+ frame = Protocol.encode(@channel, data)
43
+ @mutex.synchronize { @io.write(frame) }
44
+ data.bytesize
45
+ end
46
+
47
+ def puts(str = "")
48
+ write(str.to_s + "\n")
49
+ end
50
+
51
+ def print(str)
52
+ write(str.to_s)
53
+ end
54
+
55
+ def flush
56
+ @mutex.synchronize { @io.flush rescue nil }
57
+ end
58
+
59
+ def binmode
60
+ self
61
+ end
62
+
63
+ def close
64
+ # no-op — the underlying socket is closed by the connection
65
+ end
66
+ end
67
+
68
+ class FramedReader
69
+ def initialize(io)
70
+ @io = io
71
+ @buffer = String.new(encoding: Encoding::BINARY)
72
+ @eof = false
73
+ end
74
+
75
+ def read(length = nil, buf = nil)
76
+ if length.nil?
77
+ # Read all remaining stdin data
78
+ chunks = []
79
+ chunks << @buffer.dup unless @buffer.empty?
80
+ @buffer.clear
81
+ until @eof
82
+ data = read_next_stdin_frame
83
+ break unless data
84
+ chunks << data
85
+ end
86
+ result = chunks.join
87
+ if buf
88
+ buf.replace(result)
89
+ else
90
+ result
91
+ end
92
+ else
93
+ fill_buffer(length)
94
+ chunk = @buffer.slice!(0, length)
95
+ return nil if chunk.nil? || chunk.empty?
96
+ if buf
97
+ buf.replace(chunk)
98
+ else
99
+ chunk
100
+ end
101
+ end
102
+ end
103
+
104
+ def gets
105
+ loop do
106
+ if (idx = @buffer.index("\n"))
107
+ return @buffer.slice!(0, idx + 1)
108
+ end
109
+ return @buffer.slice!(0, @buffer.bytesize) if @eof && !@buffer.empty?
110
+ return nil if @eof
111
+ data = read_next_stdin_frame
112
+ return nil unless data
113
+ end
114
+ end
115
+
116
+ def eof?
117
+ @buffer.empty? && @eof
118
+ end
119
+
120
+ private
121
+
122
+ def fill_buffer(target)
123
+ while @buffer.bytesize < target && !@eof
124
+ data = read_next_stdin_frame
125
+ break unless data
126
+ end
127
+ end
128
+
129
+ def read_next_stdin_frame
130
+ return nil if @eof
131
+ result = Protocol.decode(@io)
132
+ if result.nil?
133
+ @eof = true
134
+ return nil
135
+ end
136
+ channel, data = result
137
+ if channel == Protocol::CHANNEL_STDIN && !data.empty?
138
+ @buffer << data
139
+ data
140
+ else
141
+ @eof = true
142
+ nil
143
+ end
144
+ end
145
+ end
146
+
147
+ class Shell < Thor::Shell::Basic
148
+ attr_reader :stdout, :stderr
149
+
150
+ def initialize(stdout, stderr, tty: false)
151
+ super()
152
+ @stdout = stdout
153
+ @stderr = stderr
154
+ @tty = tty
155
+ end
156
+
157
+ def can_display_colors?
158
+ false
159
+ end
160
+
161
+ private
162
+
163
+ def prepare_message(message, *_color)
164
+ message.to_s
165
+ end
166
+ end
167
+
168
+ class Connection
169
+ CONNECTION_TIMEOUT = 10
170
+
171
+ def initialize(socket, cli_class, logger: nil)
172
+ @socket = socket
173
+ @cli_class = cli_class
174
+ @logger = logger
175
+ end
176
+
177
+ def handle
178
+ unless IO.select([@socket], nil, nil, CONNECTION_TIMEOUT)
179
+ @logger&.warn "connection timed out"
180
+ send_control("error" => "connection timeout")
181
+ return
182
+ end
183
+
184
+ result = Protocol.decode(@socket)
185
+ unless result
186
+ @logger&.warn "empty connection"
187
+ return
188
+ end
189
+
190
+ channel, data = result
191
+ unless channel == Protocol::CHANNEL_CONTROL
192
+ @logger&.warn "expected control frame, got channel #{channel}"
193
+ return
194
+ end
195
+
196
+ control = JSON.parse(data)
197
+ args = control.fetch("args", [])
198
+ tty = control.fetch("tty", false)
199
+
200
+ @logger&.info "command: #{args.join(' ')}" unless args.empty?
201
+
202
+ stdout_writer = FramedWriter.new(@socket, Protocol::CHANNEL_STDOUT)
203
+ stderr_writer = FramedWriter.new(@socket, Protocol::CHANNEL_STDERR)
204
+ stdin_reader = FramedReader.new(@socket)
205
+
206
+ run_cli(args, stdin: stdin_reader, stdout: stdout_writer, stderr: stderr_writer, tty: tty)
207
+
208
+ send_control("exit" => 0)
209
+ rescue => e
210
+ @logger&.error "connection error: #{e.message}"
211
+ send_control("exit" => 1, "error" => e.message) rescue nil
212
+ ensure
213
+ @socket.close rescue nil
214
+ end
215
+
216
+ private
217
+
218
+ def run_cli(args, stdin:, stdout:, stderr:, tty:)
219
+ if @cli_class.respond_to?(:run)
220
+ @cli_class.run(args, stdin: stdin, stdout: stdout, stderr: stderr)
221
+ else
222
+ args = ["help"] if args.empty?
223
+ shell = Shell.new(stdout, stderr, tty: tty)
224
+ @cli_class.start(args, shell: shell)
225
+ end
226
+ rescue SystemExit
227
+ # Thor calls exit on errors — catch it so we don't kill the server
228
+ end
229
+
230
+ def send_control(data)
231
+ frame = Protocol.encode(Protocol::CHANNEL_CONTROL, JSON.generate(data))
232
+ @socket.write(frame)
233
+ end
234
+ end
235
+
236
+ class Server
237
+ attr_reader :socket_path
238
+
239
+ def initialize(cli_class, socket_path:, logger: nil)
240
+ @cli_class = cli_class
241
+ @socket_path = socket_path
242
+ @logger = logger
243
+ @server = nil
244
+ @thread = nil
245
+ end
246
+
247
+ def start
248
+ cleanup_stale_socket(@socket_path)
249
+ FileUtils.mkdir_p(File.dirname(@socket_path))
250
+ @server = UNIXServer.new(@socket_path)
251
+ @thread = Thread.new { accept_loop }
252
+ @thread.report_on_exception = false
253
+ @logger&.info "listening on #{@socket_path}"
254
+ self
255
+ end
256
+
257
+ def stop
258
+ @logger&.info "shutting down"
259
+ @server&.close
260
+ @thread&.kill
261
+ File.delete(@socket_path) if @socket_path && File.exist?(@socket_path)
262
+ end
263
+
264
+ private
265
+
266
+ def accept_loop
267
+ loop do
268
+ client = @server.accept
269
+ Thread.new(client) do |sock|
270
+ Connection.new(sock, @cli_class, logger: @logger).handle
271
+ end
272
+ end
273
+ rescue IOError, Errno::EBADF
274
+ # Server was closed, exit gracefully
275
+ end
276
+
277
+ def cleanup_stale_socket(path)
278
+ return unless File.exist?(path)
279
+ begin
280
+ test = UNIXSocket.new(path)
281
+ test.close
282
+ raise "Socket #{path} is already in use"
283
+ rescue Errno::ECONNREFUSED, Errno::ENOENT
284
+ File.delete(path)
285
+ end
286
+ end
287
+ end
288
+
289
+ class Client
290
+ def self.connect(socket_path, args:, stdin: $stdin, stdout: $stdout, stderr: $stderr, tty: $stdout.tty?)
291
+ new(socket_path, args: args, stdin: stdin, stdout: stdout, stderr: stderr, tty: tty).run
292
+ end
293
+
294
+ def initialize(socket_path, args:, stdin:, stdout:, stderr:, tty:)
295
+ @socket_path = socket_path
296
+ @args = args
297
+ @stdin = stdin
298
+ @stdout = stdout
299
+ @stderr = stderr
300
+ @tty = tty
301
+ end
302
+
303
+ def run
304
+ sock = UNIXSocket.new(@socket_path)
305
+
306
+ # Send control frame
307
+ control = { "args" => @args, "tty" => @tty }
308
+ sock.write(Protocol.encode(Protocol::CHANNEL_CONTROL, JSON.generate(control)))
309
+
310
+ # Pipe stdin if '-' is an argument and stdin isn't a tty
311
+ writer = nil
312
+ if @args.include?("-") && !@stdin.tty?
313
+ writer = Thread.new do
314
+ begin
315
+ buf = String.new(capacity: 16384)
316
+ while @stdin.read(16384, buf)
317
+ sock.write(Protocol.encode(Protocol::CHANNEL_STDIN, buf))
318
+ end
319
+ rescue IOError, Errno::EPIPE
320
+ # stdin closed or socket gone
321
+ ensure
322
+ # Send an empty stdin frame to signal EOF, then stop writing
323
+ sock.write(Protocol.encode(Protocol::CHANNEL_STDIN, "")) rescue nil
324
+ end
325
+ end
326
+ end
327
+
328
+ # Read frames from server
329
+ exit_code = 0
330
+ loop do
331
+ result = Protocol.decode(sock)
332
+ break unless result
333
+
334
+ channel, data = result
335
+ case channel
336
+ when Protocol::CHANNEL_STDOUT
337
+ @stdout.write(data)
338
+ when Protocol::CHANNEL_STDERR
339
+ @stderr.write(data)
340
+ when Protocol::CHANNEL_CONTROL
341
+ ctrl = JSON.parse(data)
342
+ exit_code = ctrl.fetch("exit", 0)
343
+ break
344
+ end
345
+ end
346
+
347
+ writer&.join
348
+ sock.close rescue nil
349
+ exit_code
350
+ end
351
+ end
352
+ end
353
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotswap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brad Gessler
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-02-28 00:00:00.000000000 Z
10
+ date: 2026-03-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: thor
@@ -163,8 +163,8 @@ files:
163
163
  - lib/hotswap/database.rb
164
164
  - lib/hotswap/middleware.rb
165
165
  - lib/hotswap/railtie.rb
166
- - lib/hotswap/socket_server.rb
167
166
  - lib/hotswap/version.rb
167
+ - lib/thor/socket.rb
168
168
  licenses:
169
169
  - MIT
170
170
  metadata: {}
@@ -1,124 +0,0 @@
1
- require "socket"
2
- require "shellwords"
3
-
4
- module Hotswap
5
- class SocketServer
6
- attr_reader :socket_path, :stderr_socket_path
7
-
8
- CONNECTION_TIMEOUT = 10
9
-
10
- def initialize(socket_path: Hotswap.socket_path, stderr_socket_path: Hotswap.stderr_socket_path)
11
- @socket_path = socket_path
12
- @stderr_socket_path = stderr_socket_path
13
- @server = nil
14
- @stderr_server = nil
15
- @thread = nil
16
- @stderr_client = nil
17
- @stderr_mutex = Mutex.new
18
- end
19
-
20
- def start
21
- cleanup_stale_socket(@socket_path)
22
- cleanup_stale_socket(@stderr_socket_path)
23
-
24
- FileUtils.mkdir_p(File.dirname(@socket_path))
25
- @server = UNIXServer.new(@socket_path)
26
- @stderr_server = UNIXServer.new(@stderr_socket_path)
27
-
28
- @thread = Thread.new { accept_loop }
29
- @thread.report_on_exception = false
30
-
31
- logger.info "listening on #{@socket_path}"
32
- logger.info "stderr socket on #{@stderr_socket_path}"
33
- logger.info "managing #{Hotswap.databases.size} database(s): #{Hotswap.databases.map(&:path).join(', ')}"
34
- self
35
- end
36
-
37
- def stop
38
- logger.info "shutting down"
39
- @server&.close
40
- @stderr_server&.close
41
- @thread&.kill
42
- @stderr_mutex.synchronize do
43
- @stderr_client&.close rescue nil
44
- @stderr_client = nil
45
- end
46
- [@socket_path, @stderr_socket_path].each do |path|
47
- File.delete(path) if path && File.exist?(path)
48
- end
49
- end
50
-
51
- private
52
-
53
- def logger = Hotswap.logger
54
-
55
- def accept_loop
56
- ios = [@server, @stderr_server]
57
- loop do
58
- readable, = IO.select(ios)
59
- readable.each do |server|
60
- client = server.accept
61
- if server == @stderr_server
62
- @stderr_mutex.synchronize do
63
- @stderr_client&.close rescue nil
64
- @stderr_client = client
65
- end
66
- else
67
- Thread.new(client) { |sock| handle_connection(sock) }
68
- end
69
- end
70
- end
71
- rescue IOError, Errno::EBADF
72
- # Server was closed, exit gracefully
73
- end
74
-
75
- def take_stderr_client
76
- @stderr_mutex.synchronize do
77
- client = @stderr_client
78
- @stderr_client = nil
79
- client
80
- end
81
- end
82
-
83
- def handle_connection(socket)
84
- unless IO.select([socket], nil, nil, CONNECTION_TIMEOUT)
85
- logger.warn "connection timed out"
86
- socket.write("ERROR: connection timeout\n") rescue nil
87
- return
88
- end
89
-
90
- line = socket.gets
91
- return unless line
92
-
93
- parts = Shellwords.split(line.strip)
94
- logger.info "command: #{parts.join(' ')}" unless parts.empty?
95
-
96
- # Grab the stderr socket if one is waiting
97
- stderr_io = take_stderr_client
98
-
99
- Hotswap::CLI.run(
100
- parts,
101
- stdin: socket,
102
- stdout: socket,
103
- stderr: stderr_io || $stderr
104
- )
105
- rescue => e
106
- logger.error "connection error: #{e.message}"
107
- socket.write("ERROR: #{e.message}\n") rescue nil
108
- ensure
109
- stderr_io&.close rescue nil
110
- socket.close rescue nil
111
- end
112
-
113
- def cleanup_stale_socket(path)
114
- return unless File.exist?(path)
115
- begin
116
- test = UNIXSocket.new(path)
117
- test.close
118
- raise Hotswap::Error, "Socket #{path} is already in use"
119
- rescue Errno::ECONNREFUSED, Errno::ENOENT
120
- File.delete(path)
121
- end
122
- end
123
- end
124
- end