rosh 0.9.1 → 0.9.4

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: c281394b7c56d31763a538fe527e83e811fbd96c9cb777e203f1e6805309e8f0
4
- data.tar.gz: 79b3f328162456af2d3b5d582ef3773244c05764a1b25b56921361d92033deee
3
+ metadata.gz: 3934908cd1559287bd808f7774a044e7e598850d814bfcfe15604df715653114
4
+ data.tar.gz: 71f530ac4d74d582a4c4145f07f941e7ca63323a0bc054e33ac7cffd72b475ef
5
5
  SHA512:
6
- metadata.gz: 2fdf8acb2e2973c428320999966fc7c071e67cb35afea53570fb1781ca718facbefecf7ee0e15d8c90aca387a8a828a6ce3ed97648c8bdeb3f4c34505d724ecf
7
- data.tar.gz: 75389ed8edf6e5a84512d52071ceeba6f618725d94841887afd3fb08a4fe285d593cdebf32ded16359f9460cd3731de15991af9eb0129d19ef121a45f6a5bbfb
6
+ metadata.gz: 922807c829ec873dbcb5a617cbcfd5b50a3d3da0cdfac24c6914223c27da717c9c29d109893402ca6f2666e3287035d34827b9efced35eaf4bc8cb7cdb6459e9
7
+ data.tar.gz: 0da6edde2a9f4825d029d2e8a37b5edf728e518d31997ede1522562e8d24d8ebd0f4a74f93211529ebb412824508a4105d0c50046775b042f3ee59469cb8851c
data/lib/rosh/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Rosh
2
- VERSION = '0.9.1'
2
+ VERSION = '0.9.4'
3
3
  end
data/lib/rosh.rb CHANGED
@@ -2,6 +2,7 @@ require 'uri'
2
2
  require 'resolv'
3
3
  require 'net/ssh/config'
4
4
  require 'optparse'
5
+ require 'socket'
5
6
  require File.join(File.dirname(__FILE__), %w[rosh version])
6
7
 
7
8
  class Rosh
@@ -29,11 +30,15 @@ class Rosh
29
30
  if @verbose
30
31
  puts "ssh-config: #{config}"
31
32
  end
33
+ @forward_opts = []
34
+ @forwarding_disabled = false
35
+ @oom_reported = false
36
+ @last_exit_status = nil
32
37
  local_forwards(alias_name).each do |f|
33
- @ssh_opts << "-L #{f}"
38
+ add_forward_option(:local, f)
34
39
  end
35
40
  remote_forwards(alias_name).each do |f|
36
- @ssh_opts << "-R #{f}"
41
+ add_forward_option(:remote, f)
37
42
  end
38
43
  @host = config[:host_name] if config[:host_name]
39
44
  @ssh_opts << "-l #{config[:user]}" if config[:user]
@@ -64,7 +69,10 @@ class Rosh
64
69
  puts "connecting to #{@host}..."
65
70
  puts cmd
66
71
  end
67
- reconnect until system cmd
72
+ begin
73
+ reconnect
74
+ end until execute_attach(cmd)
75
+ report_session_end(cmd)
68
76
  end
69
77
 
70
78
  def reconnect
@@ -98,6 +106,117 @@ class Rosh
98
106
  end
99
107
 
100
108
  private
109
+ def execute_attach(cmd)
110
+ result = system cmd
111
+ @last_exit_status = $?
112
+ report_oom_if_needed(cmd)
113
+ if @verbose && !result
114
+ log_failed_command(cmd)
115
+ end
116
+ result
117
+ end
118
+
119
+ def add_forward_option(kind, spec)
120
+ return if forwarding_disabled?
121
+ if kind == :local && !local_forward_available?(spec)
122
+ puts "skip forwarding: #{spec} is already in use"
123
+ disable_forwarding!
124
+ return
125
+ end
126
+ opt = kind == :local ? "-L #{spec}" : "-R #{spec}"
127
+ @ssh_opts << opt
128
+ @forward_opts << opt
129
+ end
130
+
131
+ def disable_forwarding!
132
+ return if @forwarding_disabled
133
+ @forwarding_disabled = true
134
+ @forward_opts.each { |opt| @ssh_opts.delete(opt) }
135
+ @forward_opts.clear
136
+ end
137
+
138
+ def forwarding_disabled?
139
+ @forwarding_disabled
140
+ end
141
+
142
+ def local_forward_available?(spec)
143
+ host, port = parse_local_forward(spec)
144
+ return true unless host && port
145
+ server = TCPServer.new(host, port)
146
+ server.close
147
+ true
148
+ rescue Errno::EADDRINUSE, Errno::EACCES, Errno::EADDRNOTAVAIL
149
+ false
150
+ rescue Errno::EPERM
151
+ true
152
+ rescue SocketError, ArgumentError
153
+ true
154
+ end
155
+
156
+ def parse_local_forward(spec)
157
+ parts = spec.split(':')
158
+ host = parts.length >= 4 ? parts.first : '127.0.0.1'
159
+ port = parts.length >= 4 ? parts[1] : parts.first
160
+ port_num = Integer(port)
161
+ [host.empty? ? '127.0.0.1' : host, port_num]
162
+ rescue ArgumentError
163
+ [nil, nil]
164
+ end
165
+
166
+ def report_session_end(cmd)
167
+ report_oom_if_needed(cmd)
168
+ log_session_end(cmd) if @verbose
169
+ end
170
+
171
+ def report_oom_if_needed(cmd)
172
+ return if @oom_reported
173
+ return unless oom_killed?(@last_exit_status)
174
+ @oom_reported = true
175
+ puts "tmux session #{@name} が SIGKILL (OOM の可能性) で終了しました。" +
176
+ " (command: #{cmd})"
177
+ end
178
+
179
+ def oom_killed?(status)
180
+ return false unless status
181
+ sig9 = status.respond_to?(:termsig) && status.termsig == 9
182
+ exit_137 = status.respond_to?(:exitstatus) && status.exitstatus == 137
183
+ sig9 || exit_137
184
+ end
185
+
186
+ def log_failed_command(cmd)
187
+ status = @last_exit_status
188
+ detail = format_status(status)
189
+ if detail
190
+ puts "ssh command failed (#{detail}) while executing: #{cmd}"
191
+ else
192
+ puts "ssh command failed while executing: #{cmd}"
193
+ end
194
+ end
195
+
196
+ def log_session_end(cmd)
197
+ status = @last_exit_status
198
+ detail = format_status(status)
199
+ puts "ssh command finished (#{detail}) for: #{cmd}"
200
+ return unless status && status.success?
201
+ prev_status = @last_exit_status
202
+ session_alive = sh_has_session?
203
+ @last_exit_status = prev_status
204
+ if session_alive
205
+ puts "tmux session #{@name} は継続中です。ユーザーデタッチまたはSSH切断で終了しました。"
206
+ else
207
+ puts "tmux session #{@name} が見つかりません。リモートシェルが即終了した可能性があります。"
208
+ end
209
+ end
210
+
211
+ def format_status(status)
212
+ return nil unless status
213
+ parts = []
214
+ parts << "exit #{status.exitstatus}" if status.exitstatus
215
+ parts << "signal #{status.termsig}" if status.signaled?
216
+ parts << "stopped #{status.stopsig}" if status.stopped?
217
+ parts.empty? ? nil : parts.join(', ')
218
+ end
219
+
101
220
  def sh(a, r=nil)
102
221
  cmd = "ssh #{resolv} #{@ssh_opts*' '} 'screen -S #{@name} #{a}' #{r}"
103
222
  if @verbose
@@ -108,23 +227,24 @@ private
108
227
 
109
228
  def sh_has_session?
110
229
  # tmux has-session -t <session_name>
111
- cmd = [
112
- "ssh",
113
- *@ssh_opts,
114
- resolv,
115
- "'tmux has-session -t #{@name} 2>/dev/null'"
116
- ]*' '
117
- puts cmd if @verbose
118
- system cmd
230
+ ssh_tmux("tmux has-session -t #{@name} 2>/dev/null")
119
231
  end
120
232
 
121
233
  def sh_new_session?
122
234
  # tmux new-session -s <session_name> -d
235
+ create_with_override = "tmux new-session -s #{@name} -d \\; set-option -t #{@name} destroy-unattached off"
236
+ return true if ssh_tmux(create_with_override)
237
+
238
+ puts "retrying tmux new-session without destroy-unattached override" if @verbose
239
+ ssh_tmux("tmux new-session -s #{@name} -d")
240
+ end
241
+
242
+ def ssh_tmux(command)
123
243
  cmd = [
124
244
  "ssh",
125
245
  *@ssh_opts,
126
246
  resolv,
127
- "'tmux new-session -s #{@name} -d'"
247
+ "'#{command}'"
128
248
  ]*' '
129
249
  puts cmd if @verbose
130
250
  system cmd
@@ -0,0 +1,59 @@
1
+ # roshセッションがクラッシュする件 調査メモ (2025-09-26)
2
+
3
+ ## 事象
4
+ - roshで `vagrant grav` に接続中、セッションが突然落ち、以下の標準出力を確認。
5
+ - `can't find session: grav`
6
+ - `bind [127.0.0.1]:3131: Address already in use`
7
+ - `Warning: remote port forwarding failed for listen port 8082`
8
+ - rosh は再接続を試みるが、最終的に `[exited]` してしまい復旧しない。
9
+
10
+ ## 原因の整理
11
+ - rosh は初期化時に `~/.ssh/config` から対象ホストの `LocalForward` と `RemoteForward` をそのまま `ssh` オプションとして引き継ぐ。(`lib/rosh.rb:32-37`)
12
+ - 対象ホスト設定に `LocalForward 3131 ...` および `RemoteForward 8082 ...` が含まれているため、接続のたびにローカル127.0.0.1:3131とリモート8082/tcpの占有を試みる。
13
+ - セッションが異常終了した直後や、別ターミナルで同じホストに接続したままの場合、既存の `ssh` プロセスがこれらのポートを掴んでおり、新しい `ssh` が `bind ... Address already in use` を返して失敗する。
14
+ - `ssh` が失敗すると rosh の `system cmd` 呼び出しが偽を返し、再接続ループ中に `tmux` セッション作成も失敗扱いとなって rosh 本体が終了する。(`lib/rosh.rb:55-90`)
15
+ - 冒頭の `can't find session: grav` は、リモート tmux セッションが異常終了したか、まだ存在しないことを示しており、ポート前提の再接続をより不安定にしている。
16
+
17
+ ## 確認ポイント
18
+ - `lsof -iTCP:3131 -sTCP:LISTEN` でローカルの占有プロセスを特定。
19
+ - `ps aux | grep ssh.*3131` で rosh/vagrant 由来の既存 SSH プロセスが残っていないか確認。
20
+ - リモートホスト側で `ss -tnlp | grep 8082` などを実行し、8082/tcp を掴んでいるプロセスの有無を確認。
21
+ - `.ssh/config` の該当 Host 節を確認し、前述の Local/RemoteForward が設定されていることを記録。
22
+
23
+ ## 対策案
24
+ ### 応急対応
25
+ - 残留している SSH プロセスを終了し、ローカル3131/tcpを解放する(例: `pkill -f 'ssh.*3131'`)。その後 rosh を再実行。
26
+ - 必要に応じてリモートで `tmux new-session -s grav -d` を手動実行し、セッションを復旧。
27
+
28
+ ### 恒久対応候補
29
+ 1. rosh 専用の Host エイリアスを `.ssh/config` に用意し、Local/RemoteForward を外す。通常の `ssh`/`vagrant` 用と分離してポート競合を防ぐ。
30
+ 2. Forward のポート番号を見直し、常に空いている番号に変更するか、ローカル側だけでも `LocalForward 0 ...` で OS 任せの空きポートを採用し、アプリ側には `SSH_CONNECTION` などから割当ポートを伝える。
31
+ 3. rosh を改修し、`bind ... Address already in use` を検知した場合は次のリトライまで `sleep` する、または Forward をスキップできるオプションを追加する(要検討)。
32
+ 4. `.ssh/config` に `ExitOnForwardFailure yes` を追加しておくと、Forward が確立できない接続を早期に失敗させられるため、原因の切り分けが容易になる。
33
+
34
+ ## 改修方針(Forward衝突時はスキップ)
35
+ - 目的: 同一ホストに対して複数の rosh セッションを開いた際に Forward が衝突する場合、Forward を諦めて tmux 再接続のみ継続させる。
36
+ - 想定フロー
37
+ 1. `local_forwards` / `remote_forwards` の戻り値をもとに `@ssh_opts` を組み立てる処理にフックし、ポートの事前占有チェックを追加。
38
+ 2. ローカル Forward については `Socket.tcp_server_sockets`、`Addrinfo#getnameinfo` 等を利用して事前に bind 可否を確認し、失敗した場合は該当ポートの `-L` オプションを除外する。
39
+ 3. リモート Forward の衝突は事前検知が難しいため、`ssh` 実行時の標準エラー出力をパイプで受け取り、`bind ... Address already in use`/`remote port forwarding failed` を検知した時点で `-R` オプションを除外して再試行する(Forward なしバージョンで再接続)。
40
+ 4. Forward を全て除外した再接続が成功した場合は警告ログを出力し、恒常的に Forward を使わないモードへ遷移(セッション終了まで)。
41
+ 5. ユーザが Forward を強制したいケース向けに、`--require-forward` のようなフラグを導入し、オプトインで従来動作を維持できるようにする案も検討。
42
+ - 実装時に考慮する点
43
+ - `OptionParser` や既存引数との後方互換性を確保する。
44
+ - `system cmd` を直接使っているため、エラーメッセージ捕捉のために `Open3.capture3` への置き換えや、`IO.popen` を導入する必要がある。
45
+ - Forward をスキップした場合でも再接続ループが破綻しないよう、`@first_try` フラグや `@ssh_opts` の再構築タイミングを整理する。
46
+
47
+ ## 実装済みの改善点 (2025-09-26)
48
+ - Forward の衝突を検知して `-L/-R` を除去し、警告だけを出して tmux 再接続を継続する挙動を実装。
49
+ - `rosh -V` 実行時は、`ssh` コマンドの終了ステータスと tmux セッション存否をログ出力し、突然 `[exited]` した際の原因切り分けを支援する。
50
+ - `tmux attach` が `SIGKILL`/exit137 で終了した場合は OOM Kill の可能性を常時警告するようにし、`-V` 指定の有無に関わらず検知できるようにした。
51
+ - バージョンを `0.9.2` に更新。
52
+
53
+ ## 追加したテスト
54
+ - `test/rosh_forwarding_test.rb`: `.ssh/config` に記載した `LocalForward`/`RemoteForward` が `@ssh_opts` に反映されること、ポート衝突を検知した場合は Forward オプションが取り除かれることを検証する Minitest。現状の Forward 振る舞いを固定化し、今後の改修時に明示的に意図を更新できるようにする。
55
+
56
+ ## メモ
57
+ - 調査日: 2025-09-26
58
+ - 関連ソース: `lib/rosh.rb:32-37`, `lib/rosh.rb:55-90`, `lib/rosh.rb:133-166`
59
+ - Forward 設定が必要なワークロードの場合は、他クライアントとポート割当が被らないよう利用時間帯や利用者単位で整理すること。
data/rosh.gemspec CHANGED
@@ -21,5 +21,6 @@ Gem::Specification.new do |spec|
21
21
 
22
22
  spec.add_dependency "net-ssh"
23
23
  spec.add_development_dependency "bundler"
24
+ spec.add_development_dependency "minitest"
24
25
  spec.add_development_dependency "rake"
25
26
  end
@@ -0,0 +1,121 @@
1
+ require 'minitest/autorun'
2
+ require 'tmpdir'
3
+ require 'fileutils'
4
+
5
+ require_relative '../lib/rosh'
6
+
7
+ class RoshForwardingTest < Minitest::Test
8
+ def setup
9
+ @original_home = ENV['HOME']
10
+ @tmp_home = Dir.mktmpdir('rosh-test-home-')
11
+ ssh_dir = File.join(@tmp_home, '.ssh')
12
+ FileUtils.mkdir_p(ssh_dir)
13
+ File.write(File.join(ssh_dir, 'config'), <<~CONFIG)
14
+ Host grav
15
+ LocalForward 127.0.0.1 3131 localhost 3131
16
+ RemoteForward 8082 localhost 8082
17
+ CONFIG
18
+ ENV['HOME'] = @tmp_home
19
+ end
20
+
21
+ def teardown
22
+ ENV['HOME'] = @original_home
23
+ FileUtils.remove_entry(@tmp_home) if @tmp_home && File.exist?(@tmp_home)
24
+ end
25
+
26
+ def test_ssh_opts_include_local_forwarding_from_ssh_config
27
+ klass = Class.new(Rosh) do
28
+ def local_forward_available?(_spec)
29
+ true
30
+ end
31
+ end
32
+ rosh = klass.new('grav')
33
+ ssh_opts = rosh.instance_variable_get(:@ssh_opts)
34
+
35
+ assert_includes ssh_opts, '-L 127.0.0.1:3131:localhost:3131'
36
+ end
37
+
38
+ def test_ssh_opts_include_remote_forwarding_from_ssh_config
39
+ klass = Class.new(Rosh) do
40
+ def local_forward_available?(_spec)
41
+ true
42
+ end
43
+ end
44
+ rosh = klass.new('grav')
45
+ ssh_opts = rosh.instance_variable_get(:@ssh_opts)
46
+
47
+ assert_includes ssh_opts, '-R 8082:localhost:8082'
48
+ end
49
+
50
+ def test_forwarding_is_skipped_when_local_port_is_in_use
51
+ klass = Class.new(Rosh) do
52
+ def local_forward_available?(_spec)
53
+ false
54
+ end
55
+ end
56
+ rosh = klass.new('grav')
57
+ ssh_opts = rosh.instance_variable_get(:@ssh_opts)
58
+
59
+ refute_includes ssh_opts, '-L 127.0.0.1:3131:localhost:3131'
60
+ refute_includes ssh_opts, '-R 8082:localhost:8082'
61
+ end
62
+
63
+ def test_verbose_logs_when_session_is_missing
64
+ rosh = Rosh.new('grav')
65
+ rosh.instance_variable_set(:@verbose, true)
66
+ rosh.instance_variable_set(:@name, 'grav')
67
+ system('true')
68
+ status = $?
69
+ rosh.instance_variable_set(:@last_exit_status, status)
70
+
71
+ out, _ = capture_io do
72
+ rosh.stub(:sh_has_session?, false) do
73
+ rosh.send(:log_session_end, 'ssh grav')
74
+ end
75
+ end
76
+
77
+ assert_includes out, 'ssh command finished (exit 0)'
78
+ assert_includes out, 'tmux session grav が見つかりません'
79
+ end
80
+
81
+ def test_oom_kill_warning_is_printed_once
82
+ rosh = Rosh.new('grav')
83
+ status = Struct.new(:termsig, :exitstatus) do
84
+ def success?; false; end
85
+ def signaled?; true; end
86
+ def stopped?; false; end
87
+ def stopsig; nil; end
88
+ end.new(9, nil)
89
+ rosh.instance_variable_set(:@last_exit_status, status)
90
+
91
+ out, _ = capture_io { rosh.send(:report_oom_if_needed, 'ssh grav') }
92
+ assert_includes out, 'SIGKILL (OOM の可能性)'
93
+
94
+ out_again, _ = capture_io { rosh.send(:report_oom_if_needed, 'ssh grav') }
95
+ assert_equal '', out_again
96
+ end
97
+
98
+ def test_tmux_new_session_disables_destroy_unattached
99
+ rosh = Rosh.new('grav', 'grav')
100
+ commands = []
101
+ rosh.stub(:system, ->(cmd) { commands << cmd; true }) do
102
+ assert rosh.send(:sh_new_session?)
103
+ end
104
+
105
+ assert_equal 1, commands.size
106
+ assert_includes commands.first, 'set-option -t grav destroy-unattached off'
107
+ end
108
+
109
+ def test_tmux_new_session_fallback_without_override
110
+ rosh = Rosh.new('grav', 'grav')
111
+ commands = []
112
+ results = [false, true].each
113
+
114
+ rosh.stub(:system, ->(cmd) { commands << cmd; results.next rescue true }) do
115
+ assert rosh.send(:sh_new_session?)
116
+ end
117
+
118
+ assert_includes commands.first, 'set-option -t grav destroy-unattached off'
119
+ refute_includes commands.last, 'set-option -t grav destroy-unattached off'
120
+ end
121
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rosh
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.9.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Genki Takiuchi
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-07-03 00:00:00.000000000 Z
11
+ date: 2025-10-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-ssh
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: rake
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -70,7 +84,9 @@ files:
70
84
  - bin/rosh
71
85
  - lib/rosh.rb
72
86
  - lib/rosh/version.rb
87
+ - memo/20250926_rosh_session_crash.md
73
88
  - rosh.gemspec
89
+ - test/rosh_forwarding_test.rb
74
90
  homepage: https://github.com/genki/rosh
75
91
  licenses:
76
92
  - MIT
@@ -94,4 +110,5 @@ rubygems_version: 3.4.15
94
110
  signing_key:
95
111
  specification_version: 4
96
112
  summary: Rosh is roaming shell
97
- test_files: []
113
+ test_files:
114
+ - test/rosh_forwarding_test.rb