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 =

up exit 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

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.expand_path(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

.monotonicObject



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.message)
  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.expand_path(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_commandObject

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.expand_path($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.expand_path("../../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