hotswap 0.1.0 → 0.1.2

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: d4648f76060073d16f095250ed5e638c4150d5094069e839b2bce9b1b471852a
4
- data.tar.gz: 592b37ce1c1f4691e9bb74d9606ef7727333a3b65f878ebff906084b9a56168e
3
+ metadata.gz: 31ba7d63578622f3d225e863e638be171c949af98f6497019a3ce01c7371c71d
4
+ data.tar.gz: 772a73f952822313044f1a28d1b5013458eafa0685772f97bdefe41372ed933b
5
5
  SHA512:
6
- metadata.gz: 869189ee05937fab2b970e31b0cbd13c6a0f36d4d127b0dfb664c793ffd1b060d07050ad0563c1cb224c68187bfa178000305c2cb4e85dad1f0e336d842b69ec
7
- data.tar.gz: 94f141f2bd7990c6a55a53b01c1491941179453361a014caf91dd2cb83f890026e9898e78b19a8e16f8a8890c175cc298efe1145616900a2eeb9149ffaa503c4
6
+ metadata.gz: f7c028345800763facf9a6a76c17d34f5b3e8c6aa930778f76f28e731248024a67c9f0b6fba6d548aa6562459c8a2c00d76aa4f4b2d3815fdf87c81e9518e2d1
7
+ data.tar.gz: e4c172d547d26c443030c720e537f95bfad0232f7a28f72458e8fb367f7b4f1c54d7a840f6c825c1bdf1892690e4e3e1c4e72e3c438b57eafa8ff9513793539f
data/exe/hotswap CHANGED
@@ -1,29 +1,26 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  require "socket"
4
- require "securerandom"
4
+ require "shellwords"
5
5
 
6
- socket_path = ENV.fetch("HOTSWAP_SOCKET", "tmp/sqlite3.sock")
7
- stderr_socket_path = ENV.fetch("HOTSWAP_STDERR_SOCKET", "tmp/sqlite3.stderr.sock")
6
+ 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
8
 
9
9
  unless File.exist?(socket_path)
10
10
  $stderr.puts "ERROR: socket not found at #{socket_path}"
11
11
  exit 1
12
12
  end
13
13
 
14
- # Connect stderr socket first (if available) so it's registered before the command runs
14
+ # Connect stderr socket first so it's ready before the command runs
15
15
  stderr_sock = nil
16
- stderr_key = SecureRandom.hex(8)
17
16
  if File.exist?(stderr_socket_path)
18
17
  stderr_sock = UNIXSocket.new(stderr_socket_path)
19
- stderr_sock.write("#{stderr_key}\n")
20
18
  end
21
19
 
22
20
  sock = UNIXSocket.new(socket_path)
23
21
 
24
- # Send command args with stderr key as first line
25
- args = ARGV.join(" ")
26
- args += " --stderr-key=#{stderr_key}" if stderr_sock
22
+ # Shell-escape args to preserve spaces in file paths
23
+ args = ARGV.map { |a| Shellwords.escape(a) }.join(" ")
27
24
  sock.write(args + "\n")
28
25
 
29
26
  # Pipe stdin to socket (in background thread)
data/lib/hotswap/cli.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "thor"
2
2
  require "tempfile"
3
+ require "fileutils"
3
4
  require "sqlite3"
4
5
 
5
6
  module Hotswap
@@ -8,6 +9,19 @@ module Hotswap
8
9
  true
9
10
  end
10
11
 
12
+ # Thread-safe IO: each connection gets its own IO via thread-local storage
13
+ # instead of swapping global $stdin/$stdout/$stderr.
14
+ def self.run(args, stdin: $stdin, stdout: $stdout, stderr: $stderr)
15
+ Thread.current[:hotswap_stdin] = stdin
16
+ Thread.current[:hotswap_stdout] = stdout
17
+ Thread.current[:hotswap_stderr] = stderr
18
+ start(args)
19
+ ensure
20
+ Thread.current[:hotswap_stdin] = nil
21
+ Thread.current[:hotswap_stdout] = nil
22
+ Thread.current[:hotswap_stderr] = nil
23
+ end
24
+
11
25
  desc "cp SRC DST", "Copy a database to/from the running server. Use 'database' to refer to the running database."
12
26
  long_desc <<~DESC
13
27
  Copy a SQLite database to or from the running server.
@@ -25,12 +39,12 @@ module Hotswap
25
39
  def cp(src, dst)
26
40
  db_path = Hotswap.database_path
27
41
  unless db_path
28
- $stderr.write("ERROR: database_path not configured\n")
42
+ io_err.write("ERROR: database_path not configured\n")
29
43
  return
30
44
  end
31
45
 
32
46
  if src == "database" && dst == "database"
33
- $stderr.write("ERROR: source and destination can't both be 'database'\n")
47
+ io_err.write("ERROR: source and destination can't both be 'database'\n")
34
48
  return
35
49
  end
36
50
 
@@ -39,7 +53,7 @@ module Hotswap
39
53
  elsif src == "database"
40
54
  pull_database(dst, db_path)
41
55
  else
42
- $stderr.write("ERROR: one of src/dst must be 'database'\n")
56
+ io_err.write("ERROR: one of src/dst must be 'database'\n")
43
57
  end
44
58
  end
45
59
 
@@ -55,13 +69,17 @@ module Hotswap
55
69
 
56
70
  desc "version", "Print the hotswap version"
57
71
  def version
58
- $stdout.write("hotswap #{Hotswap::VERSION}\n")
72
+ io_out.write("hotswap #{Hotswap::VERSION}\n")
59
73
  end
60
74
 
61
75
  private
62
76
 
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
80
+
63
81
  def push_database(src, db_path)
64
- input = (src == "-") ? $stdin : File.open(src, "rb")
82
+ input = (src == "-") ? io_in : File.open(src, "rb")
65
83
 
66
84
  dir = File.dirname(db_path)
67
85
  temp = Tempfile.new(["hotswap", ".sqlite3"], dir)
@@ -73,11 +91,11 @@ module Hotswap
73
91
  result = db.execute("PRAGMA integrity_check")
74
92
  db.close
75
93
  unless result == [["ok"]]
76
- $stderr.write("ERROR: integrity check failed\n")
94
+ io_err.write("ERROR: integrity check failed\n")
77
95
  return
78
96
  end
79
97
 
80
- $stderr.write("Swapping database...\n")
98
+ io_err.write("Swapping database...\n")
81
99
 
82
100
  Middleware::SWAP_LOCK.synchronize do
83
101
  if defined?(ActiveRecord::Base)
@@ -90,9 +108,9 @@ module Hotswap
90
108
  ActiveRecord::Base.establish_connection
91
109
  end
92
110
 
93
- $stdout.write("OK\n")
111
+ io_out.write("OK\n")
94
112
  rescue => e
95
- $stderr.write("ERROR: #{e.message}\n")
113
+ io_err.write("ERROR: #{e.message}\n")
96
114
  ensure
97
115
  input.close if input.is_a?(File)
98
116
  temp.unlink if temp && File.exist?(temp.path)
@@ -101,7 +119,7 @@ module Hotswap
101
119
 
102
120
  def pull_database(dst, db_path)
103
121
  unless File.exist?(db_path)
104
- $stderr.write("ERROR: database file not found\n")
122
+ io_err.write("ERROR: database file not found\n")
105
123
  return
106
124
  end
107
125
 
@@ -117,13 +135,13 @@ module Hotswap
117
135
  src_db.close
118
136
 
119
137
  if dst == "-"
120
- File.open(temp.path, "rb") { |f| IO.copy_stream(f, $stdout) }
138
+ File.open(temp.path, "rb") { |f| IO.copy_stream(f, io_out) }
121
139
  else
122
140
  FileUtils.cp(temp.path, dst)
123
- $stderr.write("OK\n")
141
+ io_err.write("OK\n")
124
142
  end
125
143
  rescue => e
126
- $stderr.write("ERROR: #{e.message}\n")
144
+ io_err.write("ERROR: #{e.message}\n")
127
145
  ensure
128
146
  temp.unlink if temp && File.exist?(temp.path)
129
147
  end
@@ -17,13 +17,13 @@ module Hotswap
17
17
  if app.config.hotswap.socket_path
18
18
  Hotswap.socket_path = app.config.hotswap.socket_path
19
19
  else
20
- Hotswap.socket_path = File.join(app.root, "tmp", "sqlite3.sock")
20
+ Hotswap.socket_path = File.join(app.root, "tmp", "sockets", "hotswap.sock")
21
21
  end
22
22
 
23
23
  if app.config.hotswap.stderr_socket_path
24
24
  Hotswap.stderr_socket_path = app.config.hotswap.stderr_socket_path
25
25
  else
26
- Hotswap.stderr_socket_path = File.join(app.root, "tmp", "sqlite3.stderr.sock")
26
+ Hotswap.stderr_socket_path = File.join(app.root, "tmp", "sockets", "hotswap.stderr.sock")
27
27
  end
28
28
  end
29
29
 
@@ -31,7 +31,7 @@ module Hotswap
31
31
  app.middleware.use Hotswap::Middleware
32
32
  end
33
33
 
34
- initializer "hotswap.socket_server" do
34
+ server do
35
35
  server = Hotswap::SocketServer.new
36
36
  server.start
37
37
 
@@ -1,16 +1,19 @@
1
1
  require "socket"
2
+ require "shellwords"
2
3
 
3
4
  module Hotswap
4
5
  class SocketServer
5
6
  attr_reader :socket_path, :stderr_socket_path
6
7
 
8
+ CONNECTION_TIMEOUT = 10
9
+
7
10
  def initialize(socket_path: Hotswap.socket_path, stderr_socket_path: Hotswap.stderr_socket_path)
8
11
  @socket_path = socket_path
9
12
  @stderr_socket_path = stderr_socket_path
10
13
  @server = nil
11
14
  @stderr_server = nil
12
15
  @thread = nil
13
- @stderr_clients = {}
16
+ @stderr_client = nil
14
17
  @stderr_mutex = Mutex.new
15
18
  end
16
19
 
@@ -18,6 +21,7 @@ module Hotswap
18
21
  cleanup_stale_socket(@socket_path)
19
22
  cleanup_stale_socket(@stderr_socket_path)
20
23
 
24
+ FileUtils.mkdir_p(File.dirname(@socket_path))
21
25
  @server = UNIXServer.new(@socket_path)
22
26
  @stderr_server = UNIXServer.new(@stderr_socket_path)
23
27
 
@@ -30,7 +34,10 @@ module Hotswap
30
34
  @server&.close
31
35
  @stderr_server&.close
32
36
  @thread&.kill
33
- @stderr_mutex.synchronize { @stderr_clients.each_value(&:close) rescue nil }
37
+ @stderr_mutex.synchronize do
38
+ @stderr_client&.close rescue nil
39
+ @stderr_client = nil
40
+ end
34
41
  [@socket_path, @stderr_socket_path].each do |path|
35
42
  File.delete(path) if path && File.exist?(path)
36
43
  end
@@ -45,7 +52,10 @@ module Hotswap
45
52
  readable.each do |server|
46
53
  client = server.accept
47
54
  if server == @stderr_server
48
- register_stderr_client(client)
55
+ @stderr_mutex.synchronize do
56
+ @stderr_client&.close rescue nil
57
+ @stderr_client = client
58
+ end
49
59
  else
50
60
  Thread.new(client) { |sock| handle_connection(sock) }
51
61
  end
@@ -55,57 +65,35 @@ module Hotswap
55
65
  # Server was closed, exit gracefully
56
66
  end
57
67
 
58
- def register_stderr_client(client)
59
- # stderr client sends its PID on first line so we can match it
60
- line = client.gets
61
- return client.close unless line
62
- key = line.strip
63
- @stderr_mutex.synchronize { @stderr_clients[key] = client }
64
- end
65
-
66
- def take_stderr_client(key)
67
- @stderr_mutex.synchronize { @stderr_clients.delete(key) }
68
+ def take_stderr_client
69
+ @stderr_mutex.synchronize do
70
+ client = @stderr_client
71
+ @stderr_client = nil
72
+ client
73
+ end
68
74
  end
69
75
 
70
76
  def handle_connection(socket)
71
- # First line is the command args
77
+ unless IO.select([socket], nil, nil, CONNECTION_TIMEOUT)
78
+ socket.write("ERROR: connection timeout\n") rescue nil
79
+ return
80
+ end
81
+
72
82
  line = socket.gets
73
83
  return unless line
74
84
 
75
- parts = line.strip.split(/\s+/)
76
- # Last part may be --stderr-key=<key>
77
- stderr_key = nil
78
- parts.reject! do |p|
79
- if p.start_with?("--stderr-key=")
80
- stderr_key = p.split("=", 2).last
81
- true
82
- end
83
- end
85
+ parts = Shellwords.split(line.strip)
86
+ return if parts.empty?
84
87
 
85
- # Wait briefly for the stderr client to connect
86
- stderr_io = nil
87
- if stderr_key
88
- 5.times do
89
- stderr_io = take_stderr_client(stderr_key)
90
- break if stderr_io
91
- sleep 0.01
92
- end
93
- end
88
+ # Grab the stderr socket if one is waiting
89
+ stderr_io = take_stderr_client
94
90
 
95
- # Wire the socket as stdin/stdout and stderr socket as stderr for the CLI
96
- old_stdin = $stdin
97
- old_stdout = $stdout
98
- old_stderr = $stderr
99
- begin
100
- $stdin = socket
101
- $stdout = socket
102
- $stderr = stderr_io || $stderr
103
- Hotswap::CLI.start(parts)
104
- ensure
105
- $stdin = old_stdin
106
- $stdout = old_stdout
107
- $stderr = old_stderr
108
- end
91
+ Hotswap::CLI.run(
92
+ parts,
93
+ stdin: socket,
94
+ stdout: socket,
95
+ stderr: stderr_io || $stderr
96
+ )
109
97
  rescue => e
110
98
  socket.write("ERROR: #{e.message}\n") rescue nil
111
99
  ensure
@@ -1,3 +1,3 @@
1
1
  module Hotswap
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.2"
3
3
  end
data/lib/hotswap.rb CHANGED
@@ -15,6 +15,6 @@ module Hotswap
15
15
  end
16
16
  end
17
17
 
18
- self.socket_path = "tmp/sqlite3.sock"
19
- self.stderr_socket_path = "tmp/sqlite3.stderr.sock"
18
+ self.socket_path = "tmp/sockets/hotswap.sock"
19
+ self.stderr_socket_path = "tmp/sockets/hotswap.stderr.sock"
20
20
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotswap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brad Gessler