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 +4 -4
- data/exe/hotswap +56 -27
- data/lib/hotswap/cli.rb +14 -26
- data/lib/hotswap/database.rb +35 -11
- data/lib/hotswap/railtie.rb +8 -8
- data/lib/hotswap/version.rb +1 -1
- data/lib/hotswap.rb +2 -3
- data/lib/thor/socket.rb +353 -0
- metadata +3 -3
- data/lib/hotswap/socket_server.rb +0 -124
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad93b96b0ced98527e4b48c2df94758034ea339c08cf0ef5b9aaeb60f62cd1cc
|
|
4
|
+
data.tar.gz: de80e2a8e41794142c2785d44dbeaf21114888a4e85dcf3009af4118d7ba64ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 "
|
|
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
|
-
#
|
|
23
|
-
|
|
24
|
-
sock.write(
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 == "-") ?
|
|
63
|
-
src_db.pull(destination, stderr:
|
|
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
|
-
|
|
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
|
-
|
|
62
|
+
shell.stdout.write("hotswap #{Hotswap::VERSION}\n")
|
|
73
63
|
end
|
|
74
64
|
|
|
75
65
|
private
|
|
76
66
|
|
|
77
|
-
def io_in
|
|
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 == "-"
|
data/lib/hotswap/database.rb
CHANGED
|
@@ -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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 "
|
|
60
|
+
logger.info "acquiring swap lock"
|
|
37
61
|
stderr.write("Swapping database...\n")
|
|
38
62
|
|
|
39
63
|
Middleware::SWAP_LOCK.synchronize do
|
data/lib/hotswap/railtie.rb
CHANGED
|
@@ -21,18 +21,12 @@ module Hotswap
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
# Socket
|
|
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 =
|
|
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
|
data/lib/hotswap/version.rb
CHANGED
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
|
|
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
|
data/lib/thor/socket.rb
ADDED
|
@@ -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.
|
|
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-
|
|
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
|