Module: Msnav::Ctl
- Defined in:
- lib/msnav/ctl.rb
Overview
Daemon lifecycle control: the logic behind msnav up/status/down — a
port of coderag/daemon/ctl.py with identical semantics and exit codes,
so the editor extension can branch on them the same way.
Defined Under Namespace
Classes: Probe
Constant Summary collapse
- PROBE_TIMEOUT =
seconds per health request
2.0- SPAWN_GRACE =
keep probing this long after our child dies (bind race)
3.0- EXIT_OK =
upexit codes (the extension and scripts branch on these) 0- EXIT_FAIL =
spawn failed / port held by a foreign process
1- EXIT_NO_HUB =
no coderag.yml found — refuse to spawn
2- EXIT_WRONG_ROOT =
healthy daemon on the port, but for another hub
3- BUNDLER_ENV =
A leaked Bundler context (bundle exec / RUBYOPT=-rbundler/setup) makes rubygems binstubs refuse any gem not in the active Gemfile — the daemon never needs Bundler, so these are scrubbed from the child's environment.
%w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION BUNDLER_SETUP].freeze
Class Method Summary collapse
-
.daemon_log_path(hub) ⇒ Object
Raw stdout/stderr of a spawned daemon.
-
.expected_workspace_root(hub) ⇒ Object
The workspace root a daemon started at HUB will report: coderag.yml's
workspace_rootkey resolved against the hub, else the hub itself. -
.find_hub_root(start) ⇒ Object
Nearest ancestor of START (inclusive) containing coderag.yml — the hub root a daemon may be spawned at.
- .monotonic ⇒ Object
-
.probe_health(url, timeout = PROBE_TIMEOUT) ⇒ Object
One /api/health probe: healthy daemon, some foreign process, or nothing listening.
- .real(path) ⇒ Object
-
.resolved_storage(hub) ⇒ Object
Where this hub's index/logs live — mirrors config resolution so the lifecycle commands know the log location before any daemon runs.
- .same_root(a, b) ⇒ Object
-
.self_command ⇒ Object
The command that reruns THIS msnav — the gem binstub when we were started through one, else ruby + the packaged exe.
-
.spawn_daemon(hub, config_path, host, port, service_root: nil, service_name: nil, log_path: nil) ⇒ Object
Launch
msnav daemondetached: new process group (outlives the caller), cwd = hub (so a relative workspace_root resolves there), output appended to daemon.log. - .tail_log(log_path, lines = 20) ⇒ Object
-
.wait_healthy(url, waiter, timeout, poll = 0.25) ⇒ Object
Poll until something answers health or the deadline passes.
Class Method Details
.daemon_log_path(hub) ⇒ Object
Raw stdout/stderr of a spawned daemon. Separate from coderag.log.
136 137 138 |
# File 'lib/msnav/ctl.rb', line 136 def daemon_log_path(hub) resolved_storage(hub) + "daemon.log" end |
.expected_workspace_root(hub) ⇒ Object
The workspace root a daemon started at HUB will report: coderag.yml's
workspace_root key resolved against the hub, else the hub itself.
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/msnav/ctl.rb', line 57 def expected_workspace_root(hub) hub = Pathname.new(hub) begin data = YAML.safe_load((hub + "coderag.yml").read) || {} declared = data.is_a?(Hash) ? data["workspace_root"] : nil if declared p = Pathname.new(declared.to_s.sub(/\A~/) { Dir.home }) candidate = p.absolute? ? p : hub + p return candidate.exist? ? candidate.realpath : candidate.cleanpath end rescue StandardError # a broken yml fails properly at daemon startup, not here end hub.exist? ? hub.realpath : hub end |
.find_hub_root(start) ⇒ Object
Nearest ancestor of START (inclusive) containing coderag.yml — the hub root a daemon may be spawned at. nil is the guard against rooting a daemon inside a single service directory.
44 45 46 47 48 49 50 51 52 53 |
# File 'lib/msnav/ctl.rb', line 44 def find_hub_root(start) cur = Pathname.new(File.(start)) cur = cur.realpath if cur.exist? loop do return cur if (cur + "coderag.yml").file? parent = cur.parent return nil if parent == cur cur = parent end end |
.monotonic ⇒ Object
199 200 201 |
# File 'lib/msnav/ctl.rb', line 199 def monotonic Process.clock_gettime(Process::CLOCK_MONOTONIC) end |
.probe_health(url, timeout = PROBE_TIMEOUT) ⇒ Object
One /api/health probe: healthy daemon, some foreign process, or nothing listening.
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/msnav/ctl.rb', line 75 def probe_health(url, timeout = PROBE_TIMEOUT) uri = URI.parse("#{url}/api/health") body = nil begin http = Net::HTTP.new(uri.host, uri.port) http.open_timeout = timeout http.read_timeout = timeout res = http.get(uri.request_uri) unless res.is_a?(Net::HTTPSuccess) return Probe.new(state: "foreign", health: {}, detail: "HTTP #{res.code} from #{url}") end body = res.body rescue StandardError => e return Probe.new(state: "down", health: {}, detail: e.) end health = begin JSON.parse(body) rescue JSON::ParserError return Probe.new(state: "foreign", health: {}, detail: "non-JSON response from #{url}") end unless health.is_a?(Hash) && health.key?("ok") && health.key?("root") return Probe.new(state: "foreign", health: {}, detail: "not a coderag daemon at #{url}") end Probe.new(state: "healthy", health: health, detail: "") end |
.real(path) ⇒ Object
108 109 110 111 112 |
# File 'lib/msnav/ctl.rb', line 108 def real(path) File.realpath(path) rescue SystemCallError File.(path) end |
.resolved_storage(hub) ⇒ Object
Where this hub's index/logs live — mirrors config resolution so the lifecycle commands know the log location before any daemon runs.
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'lib/msnav/ctl.rb', line 116 def resolved_storage(hub) hub = Pathname.new(hub) declared = nil begin data = YAML.safe_load((hub + "coderag.yml").read) || {} declared = data.is_a?(Hash) ? data["storage_dir"] : nil rescue StandardError nil end root = expected_workspace_root(hub) if declared p = Pathname.new(declared.to_s.sub(/\A~/) { Dir.home }) return p.absolute? ? p : (root + p).cleanpath end legacy = root + ".coderag" return legacy if (legacy + "coderag.db").exist? Datadir.hub_dir(root) end |
.same_root(a, b) ⇒ Object
104 105 106 |
# File 'lib/msnav/ctl.rb', line 104 def same_root(a, b) real(a.to_s) == real(b.to_s) end |
.self_command ⇒ Object
The command that reruns THIS msnav — the gem binstub when we were started through one, else ruby + the packaged exe.
142 143 144 145 146 147 148 149 |
# File 'lib/msnav/ctl.rb', line 142 def self_command exe = File.($PROGRAM_NAME) return [exe] if File.file?(exe) && File.executable?(exe) require "rbconfig" ruby = File.join(RbConfig::CONFIG["bindir"], RbConfig::CONFIG["ruby_install_name"]) [ruby, File.("../../exe/msnav", __dir__)] end |
.spawn_daemon(hub, config_path, host, port, service_root: nil, service_name: nil, log_path: nil) ⇒ Object
Launch msnav daemon detached: new process group (outlives the
caller), cwd = hub (so a relative workspace_root resolves there),
output appended to daemon.log. Returns [pid, waiter_thread].
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/msnav/ctl.rb', line 154 def spawn_daemon(hub, config_path, host, port, service_root: nil, service_name: nil, log_path: nil) log_path = log_path || daemon_log_path(hub) log_path.parent.mkpath argv = self_command + ["daemon", "--config", config_path.to_s, "--host", host, "--port", port.to_s] argv += ["--service-root", service_root.to_s] if service_root argv += ["--service", service_name.to_s] if service_name log_file = File.open(log_path.to_s, "ab") begin scrub = BUNDLER_ENV.each_with_object({}) { |k, h| h[k] = nil } pid = Process.spawn(scrub, *argv, chdir: hub.to_s, pgroup: true, in: File::NULL, out: log_file, err: log_file) ensure log_file.close # the child holds its own fd end [pid, Process.detach(pid)] end |
.tail_log(log_path, lines = 20) ⇒ Object
192 193 194 195 196 197 |
# File 'lib/msnav/ctl.rb', line 192 def tail_log(log_path, lines = 20) content = File.read(log_path.to_s).split("\n") content.last(lines).join("\n") rescue SystemCallError "" end |
.wait_healthy(url, waiter, timeout, poll = 0.25) ⇒ Object
Poll until something answers health or the deadline passes. A dead child is NOT failure by itself: losing the port-bind race to another window's daemon is success as long as a healthy daemon appears — the caller compares roots. After the child dies we keep probing briefly.
177 178 179 180 181 182 183 184 185 186 187 188 189 190 |
# File 'lib/msnav/ctl.rb', line 177 def wait_healthy(url, waiter, timeout, poll = 0.25) deadline = monotonic + timeout died_at = nil loop do probe = probe_health(url, [PROBE_TIMEOUT, poll * 4].min) return probe unless probe.state == "down" now = monotonic died_at = now if waiter && !waiter.alive? && died_at.nil? if now >= deadline || (!died_at.nil? && now - died_at > SPAWN_GRACE) return probe end sleep poll end end |