rosh 0.9.1 → 0.9.3

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: 4a029ca2deda02d278e112ba05e40a56dce955ffa8476091d0406ae098788bd1
4
+ data.tar.gz: 701dfa757f7371589b997e21914dfecf6af4fe5b21b8519804ff4f2e7ae36b71
5
5
  SHA512:
6
- metadata.gz: 2fdf8acb2e2973c428320999966fc7c071e67cb35afea53570fb1781ca718facbefecf7ee0e15d8c90aca387a8a828a6ce3ed97648c8bdeb3f4c34505d724ecf
7
- data.tar.gz: 75389ed8edf6e5a84512d52071ceeba6f618725d94841887afd3fb08a4fe285d593cdebf32ded16359f9460cd3731de15991af9eb0129d19ef121a45f6a5bbfb
6
+ metadata.gz: 98e03da0dbba0d8737e39729553952ada5016b5cb7e2fe73a1b5184be1cafcfdf7d76b38a373731dca8a08bca68e014ed4b0d38fed7182eca1d9567249a31da4
7
+ data.tar.gz: 8889757e1b4d933624a33d78727d2044fb36e16d58048e9e255355d8b2de8236697ceabb08eba52f63d90abb9c6f429555ffe5dc436596a3c9263399662bcdb8
data/lib/rosh/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Rosh
2
- VERSION = '0.9.1'
2
+ VERSION = '0.9.3'
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
@@ -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 設定が必要なワークロードの場合は、他クライアントとポート割当が被らないよう利用時間帯や利用者単位で整理すること。
@@ -0,0 +1,87 @@
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
+ rosh = Rosh.new('grav')
28
+ ssh_opts = rosh.instance_variable_get(:@ssh_opts)
29
+
30
+ assert_includes ssh_opts, '-L 127.0.0.1:3131:localhost:3131'
31
+ end
32
+
33
+ def test_ssh_opts_include_remote_forwarding_from_ssh_config
34
+ rosh = Rosh.new('grav')
35
+ ssh_opts = rosh.instance_variable_get(:@ssh_opts)
36
+
37
+ assert_includes ssh_opts, '-R 8082:localhost:8082'
38
+ end
39
+
40
+ def test_forwarding_is_skipped_when_local_port_is_in_use
41
+ klass = Class.new(Rosh) do
42
+ def local_forward_available?(_spec)
43
+ false
44
+ end
45
+ end
46
+ rosh = klass.new('grav')
47
+ ssh_opts = rosh.instance_variable_get(:@ssh_opts)
48
+
49
+ refute_includes ssh_opts, '-L 127.0.0.1:3131:localhost:3131'
50
+ refute_includes ssh_opts, '-R 8082:localhost:8082'
51
+ end
52
+
53
+ def test_verbose_logs_when_session_is_missing
54
+ rosh = Rosh.new('grav')
55
+ rosh.instance_variable_set(:@verbose, true)
56
+ rosh.instance_variable_set(:@name, 'grav')
57
+ system('true')
58
+ status = $?
59
+ rosh.instance_variable_set(:@last_exit_status, status)
60
+
61
+ out, _ = capture_io do
62
+ rosh.stub(:sh_has_session?, false) do
63
+ rosh.send(:log_session_end, 'ssh grav')
64
+ end
65
+ end
66
+
67
+ assert_includes out, 'ssh command finished (exit 0)'
68
+ assert_includes out, 'tmux session grav が見つかりません'
69
+ end
70
+
71
+ def test_oom_kill_warning_is_printed_once
72
+ rosh = Rosh.new('grav')
73
+ status = Struct.new(:termsig, :exitstatus) do
74
+ def success?; false; end
75
+ def signaled?; true; end
76
+ def stopped?; false; end
77
+ def stopsig; nil; end
78
+ end.new(9, nil)
79
+ rosh.instance_variable_set(:@last_exit_status, status)
80
+
81
+ out, _ = capture_io { rosh.send(:report_oom_if_needed, 'ssh grav') }
82
+ assert_includes out, 'SIGKILL (OOM の可能性)'
83
+
84
+ out_again, _ = capture_io { rosh.send(:report_oom_if_needed, 'ssh grav') }
85
+ assert_equal '', out_again
86
+ end
87
+ 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.3
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-09-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-ssh
@@ -70,7 +70,9 @@ files:
70
70
  - bin/rosh
71
71
  - lib/rosh.rb
72
72
  - lib/rosh/version.rb
73
+ - memo/20250926_rosh_session_crash.md
73
74
  - rosh.gemspec
75
+ - test/rosh_forwarding_test.rb
74
76
  homepage: https://github.com/genki/rosh
75
77
  licenses:
76
78
  - MIT
@@ -94,4 +96,5 @@ rubygems_version: 3.4.15
94
96
  signing_key:
95
97
  specification_version: 4
96
98
  summary: Rosh is roaming shell
97
- test_files: []
99
+ test_files:
100
+ - test/rosh_forwarding_test.rb