Files
brew/Library/Homebrew/services/formula_wrapper.rb
T
Mike McQuaid 3747dfb732 Restore use of package-provided service files
- Since #23312, `brew services start` always regenerated the service
  definition from the formula `service` block. Formulae that bundle
  their own service file only declare `name` there, so the generated
  plist had empty `ProgramArguments` and `launchctl bootstrap` failed
  with `Input/output error`.
- Read the installed service file instead unless the `service` block
  defines a command, the same gate `FormulaInstaller#install_service`
  uses when writing generated service files into the keg.
- Fixes https://github.com/Homebrew/brew/issues/23408.
2026-08-04 09:37:55 +01:00

424 lines
13 KiB
Ruby

# typed: strict
# frozen_string_literal: true
require "utils/output"
# Wrapper for a formula to handle service-related stuff like parsing and
# generating the service/plist files.
module Homebrew
module Services
class FormulaWrapper
include Utils::Output::Mixin
# Access the `Formula` instance.
sig { returns(Formula) }
attr_reader :formula
# Create a new `Service` instance from either a path or label.
sig { params(path_or_label: T.any(Pathname, String)).returns(T.nilable(FormulaWrapper)) }
def self.from(path_or_label)
return unless path_or_label =~ path_or_label_regex
begin
new(Formulary.factory(T.must(Regexp.last_match(1))))
rescue
nil
end
end
# Initialize a new `Service` instance with supplied formula.
sig { params(formula: Formula).void }
def initialize(formula)
@formula = formula
@status_output_success_type = T.let(nil, T.nilable(StatusOutputSuccessType))
return if System.launchctl? || System.systemctl?
raise UsageError, System::MISSING_DAEMON_MANAGER_EXCEPTION_MESSAGE
end
# Delegate access to `formula.name`.
sig { returns(String) }
def name
@name ||= T.let(formula.name, T.nilable(String))
end
# Delegate access to `formula.service?`.
sig { returns(T::Boolean) }
def service?
@service ||= T.let(formula.service?, T.nilable(T::Boolean))
end
# Delegate access to `formula.service.timed?`.
sig { returns(T::Boolean) }
def timed?
return @timed unless @timed.nil?
@timed = T.let(service? && load_service.timed?, T.nilable(T::Boolean))
@timed ||= false
end
# Delegate access to `formula.service.keep_alive?`.
sig { returns(T::Boolean) }
def keep_alive?
return @keep_alive unless @keep_alive.nil?
@keep_alive = T.let(service? && load_service.keep_alive?, T.nilable(T::Boolean))
@keep_alive ||= false
end
sig { returns(T::Array[Pathname]) }
def path_dirs
return [] unless service?
load_service.path_dirs
end
# service_name delegates with formula.plist_name or formula.service_name
# for systemd (e.g., `homebrew.<formula>`).
sig { returns(String) }
def service_name
@service_name ||= T.let(
if System.launchctl?
formula.plist_name
else # System.systemctl?
formula.service_name
end, T.nilable(String)
)
end
# service_file delegates with formula.launchd_service_path or formula.systemd_service_path for systemd.
sig { returns(Pathname) }
def service_file
@service_file ||= T.let(
if System.launchctl?
formula.launchd_service_path
else # System.systemctl?
formula.systemd_service_path
end, T.nilable(Pathname)
)
end
sig { returns(Pathname) }
def timer_file
@timer_file ||= T.let(formula.systemd_timer_path, T.nilable(Pathname))
end
sig { returns(String) }
def timer_name
@timer_name ||= T.let(timer_file.basename.to_s, T.nilable(String))
end
sig { returns(Pathname) }
def timer_dest
dest_dir + timer_file.basename
end
# Whether the service should be launched at startup
sig { returns(T::Boolean) }
def service_startup?
@service_startup ||= T.let(
if service?
load_service.requires_root?
else
false
end, T.nilable(T::Boolean)
)
end
# Path to destination service directory. If run as root, it's `boot_path`, else `user_path`.
sig { returns(Pathname) }
def dest_dir
System.root? ? System.boot_path : System.user_path
end
# Path to destination service. If run as root, it's in `boot_path`, else `user_path`.
sig { returns(Pathname) }
def dest
dest_dir + service_file.basename
end
# Returns `true` if any version of the formula is installed.
sig { returns(T::Boolean) }
def installed?
formula.any_version_installed?
end
sig { void }
def reset_cache!
@status_output_success_type = nil
end
# Returns `true` if the service is loaded, else false.
sig { params(cached: T::Boolean).returns(T::Boolean) }
def loaded?(cached: false)
if System.launchctl?
reset_cache! unless cached
status_success
else # System.systemctl?
System::Systemctl.quiet_run("status", timed? ? timer_name : service_file.basename)
end
end
# Returns `true` if service is present (e.g. .plist is present in boot or user service path), else `false`
# Accepts `type` with values `:root` for boot path or `:user` for user path.
sig { params(type: T.nilable(Symbol)).returns(T::Boolean) }
def service_file_present?(type: nil)
case type
when :root
boot_path_service_file_present?
when :user
user_path_service_file_present?
else
boot_path_service_file_present? || user_path_service_file_present?
end
end
sig { returns(T.nilable(String)) }
def owner
if System.launchctl? && dest.exist?
# read the username from the plist file
require "plist"
plist = begin
Plist.parse_xml(dest.read, marshal: false)
rescue
nil
end
plist_username = plist["UserName"] if plist
return plist_username if plist_username.present?
end
return "root" if boot_path_service_file_present?
return System.user if user_path_service_file_present?
nil
end
sig { returns(T::Boolean) }
def pid?
(pid = self.pid).present? && pid.positive?
end
sig { returns(T::Boolean) }
def error?
return false if pid?
(exit_code = self.exit_code).present? && !exit_code.zero?
end
sig { returns(T::Boolean) }
def unknown_status?
status_output.blank? && !pid?
end
# Get current PID of daemon process from status output.
sig { returns(T.nilable(Integer)) }
def pid
Regexp.last_match(1).to_i if status_output =~ pid_regex(status_type)
end
# Get current exit code of daemon process from status output.
sig { returns(T.nilable(Integer)) }
def exit_code
Regexp.last_match(1).to_i if status_output =~ exit_code_regex(status_type)
end
sig { returns(T.nilable(String)) }
def loaded_file
Regexp.last_match(1) if status_output =~ loaded_file_regex(status_type)
end
sig { returns(T::Hash[Symbol, T.anything]) }
def to_hash
hash = {
name:,
service_name:,
running: pid?,
loaded: loaded?(cached: true),
schedulable: timed?,
pid:,
exit_code:,
user: owner,
status: status_symbol,
file: service_file_present? ? dest : service_file,
registered: service_file_present?,
loaded_file:,
}
return hash unless service?
service = load_service
return hash if service.command.blank?
hash[:command] = service.manual_command
hash[:working_dir] = service.working_dir
hash[:root_dir] = service.root_dir
hash[:log_path] = service.log_path
hash[:error_log_path] = service.error_log_path
hash[:interval] = service.interval
hash[:cron] = service.cron.presence
hash
end
# Generate the service file content (plist or systemd unit),
# including any per-service user environment variable overrides,
# or read the package-provided service file if the formula's
# service block does not define a command.
sig { returns(String) }
def service_contents
if !service? || !load_service.command?
service_file.read
elsif System.launchctl?
load_service.to_plist
else
load_service.to_systemd_unit
end
end
private
# The purpose of this function is to lazy load the Homebrew::Service class
# and avoid nameclashes with the current Service module.
# It should be used instead of calling formula.service directly.
sig { returns(Homebrew::Service) }
def load_service
require "formula"
formula.service
end
sig { returns(StatusOutputSuccessType) }
def status_output_success_type
@status_output_success_type ||= if System.launchctl?
cmd = [System.launchctl.to_s, "print", "#{System.domain_target}/#{service_name}"]
output = Utils.popen_read(*cmd).chomp
if $CHILD_STATUS.present? && $CHILD_STATUS.success? && output.present?
success = true
type = :launchctl_print
else
cmd = [System.launchctl.to_s, "list", service_name]
output = Utils.popen_read(*cmd).chomp
success = T.cast($CHILD_STATUS.present? && $CHILD_STATUS.success? && output.present?, T::Boolean)
type = :launchctl_list
end
odebug cmd.join(" "), output
StatusOutputSuccessType.new(output, success, type)
else # System.systemctl?
cmd = ["status", service_name]
output = System::Systemctl.popen_read(*cmd).chomp
success = T.cast($CHILD_STATUS.present? && $CHILD_STATUS.success? && output.present?, T::Boolean)
odebug [System::Systemctl.executable, System::Systemctl.scope, *cmd].join(" "), output
StatusOutputSuccessType.new(output, success, :systemctl)
end
end
sig { returns(String) }
def status_output
status_output_success_type.output
end
sig { returns(T::Boolean) }
def status_success
status_output_success_type.success
end
sig { returns(Symbol) }
def status_type
status_output_success_type.type
end
sig { returns(Symbol) }
def status_symbol
if pid?
:started
elsif !loaded?(cached: true)
:none
elsif (exit_code = self.exit_code).present? && exit_code.zero?
if timed?
:scheduled
else
:stopped
end
elsif error?
:error
elsif unknown_status?
:unknown
else
:other
end
end
sig { params(status_type: Symbol).returns(Regexp) }
def exit_code_regex(status_type)
@exit_code_regex ||= T.let({
launchctl_list: /"LastExitStatus"\ =\ ([0-9]*);/,
launchctl_print: /last exit code = ([0-9]+)/,
systemctl: /\(code=exited, status=([0-9]*)\)|\(dead\)/,
}, T.nilable(T::Hash[Symbol, Regexp]))
@exit_code_regex.fetch(status_type)
end
sig { params(status_type: Symbol).returns(Regexp) }
def pid_regex(status_type)
@pid_regex ||= T.let({
launchctl_list: /"PID"\ =\ ([0-9]*);/,
launchctl_print: /pid = ([0-9]+)/,
systemctl: /Main PID: ([0-9]*) \((?!code=)/,
}, T.nilable(T::Hash[Symbol, Regexp]))
@pid_regex.fetch(status_type)
end
sig { params(status_type: Symbol).returns(Regexp) }
def loaded_file_regex(status_type)
@loaded_file_regex ||= T.let({
launchctl_list: //, # not available
launchctl_print: /path = (.*)/,
systemctl: /Loaded: .*? \((.*);/,
}, T.nilable(T::Hash[Symbol, Regexp]))
@loaded_file_regex.fetch(status_type)
end
sig { returns(T::Boolean) }
def boot_path_service_file_present?
boot_path = System.boot_path
return false if boot_path.blank?
(boot_path + service_file.basename).exist?
end
sig { returns(T::Boolean) }
def user_path_service_file_present?
user_path = System.user_path
return false if user_path.blank?
(user_path + service_file.basename).exist?
end
sig { returns(Regexp) }
private_class_method def self.path_or_label_regex
/homebrew(?>\.mxcl)?\.([\w+-.@]+)(\.plist|\.service)?\z/
end
class StatusOutputSuccessType
sig { returns(String) }
attr_reader :output
sig { returns(T::Boolean) }
attr_reader :success
sig { returns(Symbol) }
attr_reader :type
sig { params(output: String, success: T::Boolean, type: Symbol).void }
def initialize(output, success, type)
@output = output
@success = success
@type = type
end
end
end
end
end