Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/rubydex/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ def start(argv = ARGV)
require "rubydex"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this defeat the --server fast path? CLI.start still requires rubydex before dispatch, so rdx query --server ... loads the native extension in the client before Query#server_available? runs. That means the client keeps paying the extension load cost and can still fail when the local extension is incompatible, even if a server could answer. I think we need to move the native require into the inline commands/paths (query --schema, query without --server, console/mcp as needed) and keep the top-level dispatcher Ruby-only.


dispatch(argv.shift, argv)
rescue StandardError => e
# A command loads `rubydex/server` only when it needs it, so the constant can be absent.
# A direct reference here would raise `NameError` and hide the real error.
raise unless defined?(Rubydex::Server::Error) && e.is_a?(Rubydex::Server::Error)

# A server that does not start or answer is a runtime condition, not a defect in rdx.
warn("rdx server: #{e.message}")
exit(1)
end

# Reports `message`, then the usage text, and exits non-zero. Public because the subcommands
Expand Down
30 changes: 12 additions & 18 deletions lib/rubydex/cli/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

require "optparse"

require "rubydex/progress"

module Rubydex
module CLI
# Base class for `rdx` subcommands. A subcommand parses its own options out of `argv` and, when
Expand Down Expand Up @@ -109,16 +111,17 @@ def abort_with_usage(message)
CLI.abort_with_usage(message)
end

# Parses this command's options out of `argv`, with a banner derived from the command's own
# declaration. `-h`/`--help` prints the parser and exits, so every subcommand documents itself
# the same way. Pass `options: true` when the command accepts options beyond `--help`.
# A command with subactions passes its own `banner`. Every other command builds one from its
# declaration.
#
# A bad option reports the message and the usage text, so every subcommand rejects bad input
# the same way.
#: (?options: bool) ?{ (OptionParser parser) -> void } -> void
def parse_options!(options: false)
banner = +"Usage: rdx #{self.class.usage_form}"
banner << " [options]" if options
#: (?options: bool, ?banner: String?) ?{ (OptionParser parser) -> void } -> void
def parse_options!(options: false, banner: nil)
unless banner
banner = +"Usage: rdx #{self.class.usage_form}"
banner << " [options]" if options
end

parser = OptionParser.new do |p|
p.banner = banner
Expand All @@ -138,19 +141,10 @@ def parse_options!(options: false)
#: (IO progress_io) -> Rubydex::Graph
def build_graph(progress_io)
graph = Rubydex::Graph.configure_for_workspace(Dir.pwd)
with_timer(progress_io, "Indexing workspace...") { graph.index_workspace }
with_timer(progress_io, "Resolving graph...") { graph.resolve }
Progress.with_timer(progress_io, "Indexing workspace...") { graph.index_workspace }
Progress.with_timer(progress_io, "Resolving graph...") { graph.resolve }
graph
end

#: (IO io, String message) { -> void } -> void
def with_timer(io, message)
io.print(message)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
yield
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
io.puts(" finished in #{duration.round(2)}ms")
end
end
end
end
36 changes: 33 additions & 3 deletions lib/rubydex/cli/command/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
module Rubydex
module CLI
# `rdx query <CYPHER>` — runs a Cypher query against the workspace graph and prints the result.
# `--schema` describes the queryable schema instead, which needs no graph.
# `--schema` needs no graph. `--server` sends the query to the resident server.
class Command
class Query < Command
command "query"
Expand All @@ -20,12 +20,16 @@ class Query < Command
def run
schema = false
format = "table"
use_server = false

parse_options!(options: true) do |parser|
parser.on("--schema", "Describe the queryable schema instead of running a query") { schema = true }
parser.on("--format FORMAT", ["table", "json"], "Output format (table or json)") do |value|
format = value
end
parser.on("--server", "Run the query through the resident server for this workspace") do
use_server = true
end
end

query = argv.shift
Expand All @@ -39,6 +43,34 @@ def run

abort_with_usage("`query` requires a Cypher query argument (or pass `--schema`)") if query.nil? || query.empty?

if use_server && server_available?
query_through_server(query, format)
else
run_inline(query, format)
end
end

private

# The require is cheap, because the client side loads no native extension. An unsupported
# platform falls back to the inline path.
#: -> bool
def server_available?
require "rubydex/server"

Rubydex::Server.supported?
end

# The server parses and runs the query, so this process forwards a string and loads no
# native extension.
#: (String query, String format) -> bot
def query_through_server(query, format)
state = Rubydex::Server::State.new(workspace_path: Dir.pwd)
exit(Rubydex::Server::Client.query(state, { query: query, query_format: format }))
end

#: (String query, String format) -> void
def run_inline(query, format)
# Parse the query up front so a malformed query fails fast, before the expensive indexing.
parsed = parse_query(query)

Expand All @@ -48,8 +80,6 @@ def run
render(parsed, graph, format)
end

private

#: (String query) -> Rubydex::Query
def parse_query(query)
Rubydex::Query.parse(query)
Expand Down
70 changes: 70 additions & 0 deletions lib/rubydex/cli/command/server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frozen_string_literal: true

require "rubydex/cli/command"

module Rubydex
module CLI
class Command
class Server < Command
command "server"
arguments "<action>"
summary "Manage the resident server (start, stop, restart, status)"

ACTIONS = ["start", "stop", "restart", "status"].freeze #: Array[String]

USAGE = <<~TEXT #: String
Usage: rdx server <action> [options]

Actions:
start Start the server for this workspace
stop Stop the running server for this workspace
restart Restart the server for this workspace
status Print the status of the server for this workspace
TEXT

#: -> void
def run
# Options are parsed before the action is shifted, so a flag is not read as the action.
# `OptionParser#parse!` permutes, so the action may come before or after a flag.
parse_options!(options: true, banner: USAGE)

action = argv.shift
abort_with_actions("unknown server action: #{action.inspect}") unless ACTIONS.include?(action)

require "rubydex/server"

unless Rubydex::Server.supported?
abort("rdx server mode is not supported on this platform " \
"(requires fork, UNIX sockets and O_NOFOLLOW)")
end

exit(dispatch_action(action))
end

private

#: (String action) -> Integer
def dispatch_action(action)
state = Rubydex::Server::State.new(workspace_path: Dir.pwd)

case action
when "start" then Rubydex::Server::Commands.start(state)
when "stop" then Rubydex::Server::Commands.stop(state)
when "restart" then Rubydex::Server::Commands.restart(state)
else Rubydex::Server::Commands.status(state)
end
end

# Uses the action list, not the top-level command list, because the error is about an action
# of this command.
#: (String message) -> void
def abort_with_actions(message)
warn(message)
warn("")
warn(USAGE)
exit(1)
end
end
end
end
end
24 changes: 24 additions & 0 deletions lib/rubydex/progress.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module Rubydex
# The CLI and the server print the same progress lines, so the measurement is defined here, not
# in either of them.
module Progress
class << self
# The server passes a `nil` `io` when it has no log.
#: (IO? io, String message) { -> void } -> void
def with_timer(io, message)
unless io
yield
return
end

io.print(message)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
yield
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
io.puts(" finished in #{duration.round(2)}ms")
end
end
end
end
42 changes: 42 additions & 0 deletions lib/rubydex/server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true

require "rubydex/progress"
require "rubydex/version"

module Rubydex
# Client/server mode for the `rdx` executable.
#
# A resident server process indexes and resolves the workspace once, and keeps the graph in
# memory. Later commands reach it over a UNIX domain socket and skip that work.
module Server
# Increase this after any incompatible change to the request or response shape.
PROTOCOL = 1

class Error < StandardError; end

class << self
# Without `O_NOFOLLOW`, a symlink can redirect the permissions this code sets on its runtime
# directory.
#: -> bool
def supported?
Process.respond_to?(:fork) && defined?(::UNIXSocket) && !Gem.win_platform? &&
!State::NOFOLLOW.nil?
end

#: (workspace_path: String, ?progress_io: IO?) -> Rubydex::Graph
def build_graph(workspace_path:, progress_io: nil)
# The server boot must build the same graph as the inline CLI path.
graph = Rubydex::Graph.configure_for_workspace(workspace_path)
Progress.with_timer(progress_io, "Indexing workspace...") { graph.index_workspace }
Progress.with_timer(progress_io, "Resolving graph...") { graph.resolve }
graph
end
end
end
end

require "rubydex/server/state"
require "rubydex/server/frame"
require "rubydex/server/core"
require "rubydex/server/client"
require "rubydex/server/commands"
Loading
Loading