From 71413037c555613d949cda8ae525f77c080a97b5 Mon Sep 17 00:00:00 2001 From: sh84 Date: Thu, 25 Sep 2025 17:31:36 +0300 Subject: [PATCH 1/3] Run file with process group kill --- lib/daemontools/version.rb | 2 +- templates/rvm.erb | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/daemontools/version.rb b/lib/daemontools/version.rb index dd28d9d..78332fd 100644 --- a/lib/daemontools/version.rb +++ b/lib/daemontools/version.rb @@ -1,3 +1,3 @@ module Daemontools - VERSION = "0.2.8" + VERSION = "0.2.9" end diff --git a/templates/rvm.erb b/templates/rvm.erb index 78455d8..d777737 100644 --- a/templates/rvm.erb +++ b/templates/rvm.erb @@ -1,3 +1,9 @@ -/bin/bash -l -c '<%= @command.gsub('&&', "\n") %> 2>&1 & -trap "kill $!" exit INT TERM -wait' \ No newline at end of file +/bin/bash -l -c ' +<%= @command.gsub('&&', "\n") %> 2>&1 & +pid=$! +pgid=$(ps -o pgid= -p $pid | tr -d " ") + +trap "kill -TERM -$pgid" EXIT INT TERM + +wait $pid +' \ No newline at end of file From 1e5d78cee32093807da9d6c995104fd192988e91 Mon Sep 17 00:00:00 2001 From: sh84 Date: Mon, 6 Oct 2025 18:10:53 +0300 Subject: [PATCH 2/3] Run file with process group kill - v2 --- templates/rvm.erb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/templates/rvm.erb b/templates/rvm.erb index d777737..f44d3be 100644 --- a/templates/rvm.erb +++ b/templates/rvm.erb @@ -3,7 +3,13 @@ pid=$! pgid=$(ps -o pgid= -p $pid | tr -d " ") -trap "kill -TERM -$pgid" EXIT INT TERM +trap " + echo \"Sending SIGTERM to main process PID \$pid\" + kill -TERM \$pid + sleep 0.5 + echo \"Sending SIGKILL to process group PGID \$pgid\" + kill -KILL -\$pgid +" EXIT INT TERM wait $pid ' \ No newline at end of file From 2f3ad19bdf195ba19129e88b83adcb0e235d135d Mon Sep 17 00:00:00 2001 From: Danila Fedorov Date: Tue, 10 Feb 2026 15:20:43 +0300 Subject: [PATCH 3/3] Add thread safety --- lib/daemontools.rb | 180 ++++++++-------------------------- lib/daemontools/service.rb | 194 +++++++++++++++++++++++++++++++++++++ lib/daemontools/version.rb | 2 +- spec/service_spec.rb | 157 ++++++++++++++++++++++++++++++ 4 files changed, 394 insertions(+), 139 deletions(-) create mode 100644 lib/daemontools/service.rb create mode 100644 spec/service_spec.rb diff --git a/lib/daemontools.rb b/lib/daemontools.rb index 01b0b0d..a5e9676 100644 --- a/lib/daemontools.rb +++ b/lib/daemontools.rb @@ -1,4 +1,5 @@ require 'daemontools/version' +require 'daemontools/service' require 'daemontools/service_builder' require 'daemontools/service_remover' require 'etc' @@ -12,184 +13,87 @@ class << self @log_root = '/var/log/svc' @tmp_root = '/tmp' - def self.exists?(name) - check_service_exists(name, false) - end - - def self.tmp_exists?(name) - Dir.exists?("#{@tmp_root}/daemontools_service_#{name}") - end + # Actions - def self.status(name) - check_service_exists(name) - r = `sudo svstat #{@path} 2>&1` - raise r if $?.exitstatus != 0 - raise "Unknown status" unless r.match(/.*?:\s*(\S+).*\s(\d+) seconds.*/) - [$1, $2.to_i] + def self.add(name, command, options = {}) + Service[name].add(command, options) end - def self.up?(name) - status(name)[0] == "up" + def self.delete(name, rm_cmd = nil) + Service[name].delete(rm_cmd) end - def self.down?(name) - status(name)[0] == "down" + def self.start(name) + Service[name].start end def self.stop(name) - run_svc(name, 'd') - end - - def self.start(name) - run_svc(name, 'u') + Service[name].stop end def self.restart(name) - run_svc(name, 't') + Service[name].restart end - def self.add_empty(name) - path = "#{@svc_root}/#{name}" - Dir.mkdir(path) unless Dir.exists?(path) - File.open("#{path}/down", 'w') {|f| f.write('')} - now = Time.now.to_f - while `sudo svstat #{path} 2>&1`.match(/unable to open/i) - raise "Timeout wait for svc add service" if Time.now.to_f - now > 10 - sleep 0.1 - end - File.delete("#{path}/down") - stop(name) - true - end + # Statuses - def self.add_empty_tmp(name) - path = "#{@tmp_root}/daemontools_service_#{name}" - Dir.mkdir(path) unless Dir.exists?(path) - true + def self.status(name) + Service[name].status end - def self.move_tmp(name) - tmp_path = "#{@tmp_root}/daemontools_service_#{name}" - svc_path = "#{@svc_root}/#{name}" - - r = `mv #{tmp_path} #{svc_path}` - raise r if $?.exitstatus != 0 - raise r if ! r.empty? - - now = Time.now.to_f - while `sudo svstat #{svc_path} 2>&1`.match(/unable to open/i) - raise "Timeout wait for svc add service" if Time.now.to_f - now > 10 - sleep 0.1 - end + def self.up?(name) + Service[name].up? + end - true + def self.down?(name) + Service[name].down? end - def self.add(name, command, options = {}) - @name = name - @command = command - @log_dir = options[:log_dir] || "#{@log_root}/#{@name}" - @pre_command = options[:pre_command] - @sleep = options[:sleep] || 3 - @path = "#{@svc_root}/#{name}" - @log_path = "#{@path}/log" - @change_user_command = options[:change_user_command] - @ulimit = options[:ulimit] - @write_time = options[:write_time] - - if Dir.exists?(@path) - stop(name) - else - Dir.mkdir(@path) - end - File.open("#{@path}/down", 'w') {|f| f.write('')} - Dir.mkdir(@log_path) unless Dir.exists?(@log_path) - File.open("#{@log_path}/down", 'w') {|f| f.write('')} - File.open("#{@log_path}/run", 'w', 0755) {|f| f.write(run_template('log.erb'))} - File.open("#{@path}/run", 'w', 0755) {|f| f.write(run_template('run.erb'))} - - unless options[:not_wait] - wait_timeout = options[:wait_timeout] || 10 - now = Time.now.to_f - while `sudo svstat #{@path} 2>&1`.match(/unable to open/i) - raise "Timeout wait for svc add service" if Time.now.to_f - now > wait_timeout - sleep 0.1 - end - end - - true + def self.exists?(name) + Service[name].check_service_exists end - def self.delete(name, rm_cmd = nil) - return false unless exists?(name) - stop(name) - sleep 0.3 - cmd = rm_cmd.nil? ? "sudo rm -rf #{@path} 2>&1" : "#{rm_cmd} #{@path}" - r = `#{cmd}` - raise r if $?.exitstatus != 0 - true + def self.check_service_exists(name, raise_error = true) + Service[name].check_service_exists(raise_error) end + # Run States + def self.run_status(name) - check_service_exists(name) - File.exists?("#{@path}/down") ? "down" : "up" + Service[name].run_status end def self.run_status_up?(name) - run_status(name) == "up" + Service[name].run_status_up? end def self.run_status_down?(name) - run_status(name) == "down" + Service[name].run_status_down? end def self.make_run_status_up(name) - File.delete("#{@path}/down") - File.delete("#{@log_path}/down") if Dir.exists?(@log_path) - true + Service[name].run_status_up! end def self.make_run_status_down(name) - check_service_exists(name) - File.open("#{@path}/down", 'w') {|f| f.write('')} + Service[name].run_status_down! + end - if Dir.exists?(@log_path) - File.open("#{@log_path}/down", 'w') { |f| f.write('') } - end + # Tmp Actions - true + def self.add_empty(name) + Service[name].add_empty end - private + def self.add_empty_tmp(name) + Service[name].add_empty_tmp + end - def self.check_service_exists(name, raise_error = true) - @path = "#{@svc_root}/#{name}" - @log_path = "#{@path}/log" - if raise_error - raise "Service #{name} not exists" unless Dir.exists?(@path) - else - Dir.exists?(@path) - end - end - - def self.run_svc(name, command) - check_service_exists(name) - r = `sudo svc -#{command} #{@path} 2>&1` - raise r if $?.exitstatus != 0 - raise r if ! r.empty? - - if Dir.exists?(@log_path) - r = `sudo svc -#{command} #{@log_path} 2>&1` - raise r if $?.exitstatus != 0 - raise r if ! r.empty? - end - - true - end - - def self.run_template(template_name) - @user = Etc.getpwuid(Process.uid).name - template_path = File.expand_path(File.dirname(__FILE__))+'/../templates/'+template_name - ERB.new(File.read(template_path)).result(binding()) + def self.move_tmp(name) + Service[name].move_tmp + end + + def self.tmp_exists?(name) + Service[name].tmp_exists? end end diff --git a/lib/daemontools/service.rb b/lib/daemontools/service.rb new file mode 100644 index 0000000..bc1fe49 --- /dev/null +++ b/lib/daemontools/service.rb @@ -0,0 +1,194 @@ +module Daemontools + class Service + + CACHED_SERVICES = {} + + def self.[](name) + CACHED_SERVICES[name] ||= new(name) + end + + def initialize(name) + @name = name + @path = "#{Daemontools.svc_root}/#{name}" + @log_path = "#{@path}/log" + end + + # Actions + + def add(command, options) + apply_options(command: command, **options) + + Dir.exist?(@path) ? stop : Dir.mkdir(@path) + Dir.mkdir(@log_path) unless Dir.exist?(@log_path) + + File.open("#{@path}/down", 'w') { |f| f.write('') } + File.open("#{@log_path}/down", 'w') { |f| f.write('') } + File.open("#{@log_path}/run", 'w', 0o755) { |f| f.write(run_template('log.erb')) } + File.open("#{@path}/run", 'w', 0o755) { |f| f.write(run_template('run.erb')) } + + wait_start(options[:wait_timeout]) unless options[:not_wait] + + true + end + + def delete(rm_cmd) + return false unless check_service_exists(false) + + stop + sleep 0.3 + cmd = rm_cmd.nil? ? "sudo rm -rf #{@path} 2>&1" : "#{rm_cmd} #{@path}" + r = `#{cmd}` + raise r if $?.exitstatus != 0 + + CACHED_SERVICES.delete(@name) + true + end + + def stop + run_svc('d') + end + + def start + run_svc('u') + end + + def restart + run_svc('t') + end + + # Statuses + + def status + check_service_exists + r = `sudo svstat #{@path} 2>&1` + raise r if $?.exitstatus != 0 + raise 'Unknown status' unless r.match(/.*?:\s*(\S+).*\s(\d+) seconds.*/) + + [::Regexp.last_match(1), ::Regexp.last_match(2).to_i] + end + + def up? + status[0] == 'up' + end + + def down? + status[0] == 'down' + end + + def check_service_exists(raise_error = true) + exists = Dir.exist?(@path) + raise_error && !exists ? raise("Service #{@name} not exists") : exists + end + + # Run States + + def run_status + check_service_exists + File.exist?("#{@path}/down") ? 'down' : 'up' + end + + def run_status_up? + run_status == 'up' + end + + def run_status_down? + run_status == 'down' + end + + def run_status_up! + File.delete("#{@path}/down") + File.delete("#{@log_path}/down") if Dir.exist?(@log_path) + true + end + + def run_status_down! + check_service_exists + File.open("#{@path}/down", 'w') { |f| f.write('') } + File.open("#{@log_path}/down", 'w') { |f| f.write('') } if Dir.exist?(@log_path) + + true + end + + # Tmp Actions + + def add_empty + Dir.mkdir(@path) unless Dir.exist?(@path) + File.open("#{@path}/down", 'w') { |f| f.write('') } + + wait_start(10) + + File.delete("#{@path}/down") + stop + + true + end + + def add_empty_tmp + path = "#{Daemontools.tmp_root}/daemontools_service_#{@name}" + Dir.mkdir(path) unless Dir.exist?(path) + + true + end + + def move_tmp + tmp_path = "#{Daemontools.tmp_root}/daemontools_service_#{@name}" + svc_path = @path + + r = `mv #{tmp_path} #{svc_path}` + raise r if $?.exitstatus != 0 + raise r unless r.empty? + + wait_start(10) + + true + end + + def tmp_exists? + Dir.exist?("#{Daemontools.tmp_root}/daemontools_service_#{@name}") + end + + private + + def run_svc(command) + check_service_exists + r = `sudo svc -#{command} #{@path} 2>&1` + raise r if $?.exitstatus != 0 + raise r unless r.empty? + + return true unless Dir.exist?(@log_path) + + r = `sudo svc -#{command} #{@log_path} 2>&1` + raise r if $?.exitstatus != 0 + raise r unless r.empty? + + true + end + + def run_template(template_name) + @user = Etc.getpwuid(Process.uid).name + template_path = "#{__dir__}/../templates/#{template_name}" + ERB.new(File.read(template_path)).result(binding) + end + + def apply_options(**options) + @command = options[:command] + @log_dir = options[:log_dir] || "#{Daemontools.log_root}/#{@name}" + @pre_command = options[:pre_command] + @sleep = options[:sleep] || 3 + @change_user_command = options[:change_user_command] + @ulimit = options[:ulimit] + @write_time = options[:write_time] + end + + def wait_start(wait_timeout = nil) + wait_timeout ||= 10 + now = Time.now.to_f + + while `sudo svstat #{@path} 2>&1`.match(/unable to open/i) + raise 'Timeout wait for svc add service' if Time.now.to_f - now > wait_timeout + + sleep 0.1 + end + end + end +end diff --git a/lib/daemontools/version.rb b/lib/daemontools/version.rb index 78332fd..30f9e7d 100644 --- a/lib/daemontools/version.rb +++ b/lib/daemontools/version.rb @@ -1,3 +1,3 @@ module Daemontools - VERSION = "0.2.9" + VERSION = "0.2.10" end diff --git a/spec/service_spec.rb b/spec/service_spec.rb new file mode 100644 index 0000000..eb27fa0 --- /dev/null +++ b/spec/service_spec.rb @@ -0,0 +1,157 @@ +require 'daemontools' + +RSpec.describe Daemontools::Service do + let(:svc_root) { '/tmp/svc_root' } + let(:tmp_root) { '/tmp/tmp_root' } + let(:log_root) { '/tmp/log_root' } + + before do + stub_const('Daemontools::Service::CACHED_SERVICES', {}) + + allow(Daemontools).to receive(:svc_root).and_return(svc_root) + allow(Daemontools).to receive(:tmp_root).and_return(tmp_root) + allow(Daemontools).to receive(:log_root).and_return(log_root) + + FileUtils.mkdir_p(svc_root) + FileUtils.mkdir_p(tmp_root) + FileUtils.mkdir_p(log_root) + end + + after do + FileUtils.rm_rf(svc_root) + FileUtils.rm_rf(tmp_root) + FileUtils.rm_rf(log_root) + end + + describe '.[]' do + it 'caches and returns the same instance for the same name' do + s1 = described_class['test'] + s2 = described_class['test'] + expect(s1).to be_a(described_class) + expect(s1).to equal(s2) + end + end + + describe '#check_service_exists' do + let(:service) { described_class.new('svc1') } + + it 'returns false when the directory does not exist' do + expect(service.check_service_exists(false)).to be false + end + + it 'raises an error when directory is missing and raise_error=true' do + expect { service.check_service_exists(true) }.to raise_error(/not exists/) + end + + it 'returns true when the directory exists' do + FileUtils.mkdir_p("#{svc_root}/svc1") + expect(service.check_service_exists(false)).to be true + end + end + + describe '#delete' do + let(:service) { described_class.new('svc1') } + + before do + FileUtils.mkdir_p("#{svc_root}/svc1") + allow(service).to receive(:stop).and_return(true) + allow(service).to receive(:`).and_return('') + allow($?).to receive(:exitstatus).and_return(0) + Daemontools::Service::CACHED_SERVICES['svc1'] = service + end + + it 'removes the service from cache and returns true' do + expect(service.delete(nil)).to be true + expect(Daemontools::Service::CACHED_SERVICES).not_to have_key('svc1') + end + + it 'returns false when service does not exist' do + FileUtils.rm_rf("#{svc_root}/svc1") + expect(service.delete(nil)).to be false + end + + it 'raises an error when command exit status is non‑zero' do + allow($?).to receive(:exitstatus).and_return(1) + expect { service.delete(nil) }.to raise_error(RuntimeError) + end + end + + describe '#status' do + let(:service) { described_class.new('svc2') } + let(:output) { 'svc2: up (pid 1234) 5 seconds' } + + before do + FileUtils.mkdir_p("#{svc_root}/svc2") + allow(service).to receive(:`).and_return(output) + allow($?).to receive(:exitstatus).and_return(0) + end + + it 'returns [status, seconds] for valid svstat output' do + expect(service.status).to eq(%w[up 5].tap { |a| a[1] = 5 }) + end + + it 'raises "Unknown status" for unexpected output' do + allow(service).to receive(:`).and_return('something weird') + expect { service.status }.to raise_error('Unknown status') + end + + it 'raises an error when command fails' do + allow($?).to receive(:exitstatus).and_return(1) + expect { service.status }.to raise_error(/svc2/) + end + end + + describe '#run_status_*' do + let(:service) { described_class.new('svc3') } + + before do + FileUtils.mkdir_p("#{svc_root}/svc3") + end + + it 'returns "up" when no down file exists' do + expect(service.run_status).to eq('up') + end + + it 'returns "down" when down file exists' do + FileUtils.touch("#{svc_root}/svc3/down") + expect(service.run_status).to eq('down') + end + + it '#run_status_up! removes down files' do + FileUtils.mkdir_p("#{svc_root}/svc3/log") + FileUtils.touch("#{svc_root}/svc3/down") + FileUtils.touch("#{svc_root}/svc3/log/down") + service.run_status_up! + expect(File).not_to exist("#{svc_root}/svc3/down") + expect(File).not_to exist("#{svc_root}/svc3/log/down") + end + + it '#run_status_down! creates down files' do + FileUtils.mkdir_p("#{svc_root}/svc3/log") + service.run_status_down! + expect(File).to exist("#{svc_root}/svc3/down") + expect(File).to exist("#{svc_root}/svc3/log/down") + end + end + + describe '#tmp_exists?' do + let(:service) { described_class.new('svc_tmp') } + + it 'returns true when temporary directory exists' do + FileUtils.mkdir_p("#{tmp_root}/daemontools_service_svc_tmp") + expect(service.tmp_exists?).to be true + end + + it 'returns false when temporary directory is missing' do + FileUtils.rm_rf("#{tmp_root}/daemontools_service_svc_tmp") + expect(service.tmp_exists?).to be false + end + end + + describe '.CACHED_SERVICES' do + it 'holds created instances' do + s1 = described_class['foo'] + expect(Daemontools::Service::CACHED_SERVICES['foo']).to eq(s1) + end + end +end