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 +4 -4
- data/lib/rosh/version.rb +1 -1
- data/lib/rosh.rb +122 -3
- data/memo/20250926_rosh_session_crash.md +59 -0
- data/test/rosh_forwarding_test.rb +87 -0
- metadata +6 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4a029ca2deda02d278e112ba05e40a56dce955ffa8476091d0406ae098788bd1
|
4
|
+
data.tar.gz: 701dfa757f7371589b997e21914dfecf6af4fe5b21b8519804ff4f2e7ae36b71
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 98e03da0dbba0d8737e39729553952ada5016b5cb7e2fe73a1b5184be1cafcfdf7d76b38a373731dca8a08bca68e014ed4b0d38fed7182eca1d9567249a31da4
|
7
|
+
data.tar.gz: 8889757e1b4d933624a33d78727d2044fb36e16d58048e9e255355d8b2de8236697ceabb08eba52f63d90abb9c6f429555ffe5dc436596a3c9263399662bcdb8
|
data/lib/rosh/version.rb
CHANGED
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
|
-
|
38
|
+
add_forward_option(:local, f)
|
34
39
|
end
|
35
40
|
remote_forwards(alias_name).each do |f|
|
36
|
-
|
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
|
-
|
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.
|
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-
|
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
|