dante 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa7b32fa2eed7d038ee4835edd3155967b25f62360ba553c1f153004c8ccf0f6
4
- data.tar.gz: 8e4d92c8ade70fd93d399744324de5205dbdb8c9f3b4c25a4b2445d0857a56d8
3
+ metadata.gz: 91147e1905316ceab12eff8d686555b8f2daa27d81d5065ab11d9b44e8594a2a
4
+ data.tar.gz: 2d385615f43931b2f3375dce1a32ccaa6655111722e208461adb44e850e26435
5
5
  SHA512:
6
- metadata.gz: 97423c3af18736da37830316eee8ade12cf3d1205ec158a70743a65ceb99e9df9f6268ff7e3946aa66808e93eb7c2c38550ee647c8dff874dd796aa4b815fa7c
7
- data.tar.gz: 30d083e38a48c5a6d64c9a0af91a441c577199b833c38176d0bc4f470b1fd7a2861d6fa95f998c9f690203e6e039f1670abc73939db5c0fcc7b383112eee48b4
6
+ metadata.gz: 22daf007c266f02863001eaca06415e7ab8711083e9cde13fee5be196f4fbeae5af9382fc58410952df1ad8702c3cb1d2caa3f1a47faf971740973c2656a729d
7
+ data.tar.gz: b06a655e3de739bb52ec35163d6000bed1903c83a7ee37639ff5cf5e43edaf7a65074e902612c36d5b92008db51289400031a4c6c6c7dbe03dca294871fc26cb
@@ -0,0 +1,6 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "github-actions"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
@@ -18,9 +18,9 @@ jobs:
18
18
  ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4', '4.0']
19
19
 
20
20
  steps:
21
- - uses: actions/checkout@v4
21
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
22
22
  - name: Set up Ruby
23
- uses: ruby/setup-ruby@v1
23
+ uses: ruby/setup-ruby@de492cf36cbab201753d720ce4f4e336cd688c94 # v1.336.0
24
24
  with:
25
25
  ruby-version: ${{ matrix.ruby-version }}
26
26
  bundler-cache: true # runs 'bundle install' and caches installed gems automatically
@@ -0,0 +1,69 @@
1
+ # Creates the GitHub release page for a tag that already exists, with GitHub's generated notes (merged
2
+ # PRs and new contributors since the previous release) after a link to CHANGELOG.md as of that tag (the
3
+ # default branch's for an old tag that has none).
4
+ # release.yml runs it after it publishes; "Run workflow" here (with the tag) creates the page for an
5
+ # earlier release. A tag that already has a release is left alone, so running it twice is harmless.
6
+ # A tag that isn't the newest vX.Y.Z tag is created with --latest=false; the API otherwise marks every
7
+ # new release as Latest.
8
+
9
+ name: github-release
10
+ run-name: github release ${{ inputs.tag }}
11
+
12
+ on:
13
+ workflow_dispatch:
14
+ inputs:
15
+ tag:
16
+ description: 'An existing tag, e.g. v1.2.0'
17
+ required: true
18
+ type: string
19
+ workflow_call:
20
+ inputs:
21
+ tag:
22
+ required: true
23
+ type: string
24
+
25
+ permissions:
26
+ contents: read
27
+
28
+ jobs:
29
+ release-page:
30
+ if: github.repository == 'nesquena/dante'
31
+ runs-on: ubuntu-latest
32
+ permissions:
33
+ contents: write # creates the release
34
+ steps:
35
+ - name: Create the GitHub release
36
+ shell: bash -eo pipefail {0}
37
+ env:
38
+ TAG: ${{ inputs.tag }}
39
+ CHANGELOG: CHANGELOG.md
40
+ GH_TOKEN: ${{ github.token }}
41
+ run: |
42
+ [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::'$TAG' is not a tag like v1.2.3"; exit 1; }
43
+ if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
44
+ echo "$TAG already has a release; leaving it alone"
45
+ exit 0
46
+ fi
47
+ # Compare with the newest vX.Y.Z tag rather than the "Latest" release, so a failed lookup stops the
48
+ # step (pipefail) instead of letting an older tag become Latest
49
+ newest=$(gh api --paginate "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" --jq '.[].ref' |
50
+ sed -nE 's#^refs/tags/(v[0-9]+\.[0-9]+\.[0-9]+)$#\1#p' | sort -V | tail -n 1) ||
51
+ { echo "::error::could not list the repository's tags"; exit 1; }
52
+ [ -n "$newest" ] || { echo "::error::found no vX.Y.Z tag (is $TAG pushed?)"; exit 1; }
53
+ flags=()
54
+ if [ "$newest" != "$TAG" ]; then
55
+ echo "$TAG is not the newest tag ($newest): not marking it as latest"
56
+ flags=(--latest=false)
57
+ fi
58
+ # Link the changelog as of the tag, or the default branch's for an old tag without one. Any error
59
+ # but a 404 stops the step.
60
+ ref=$TAG
61
+ if ! err=$(gh api "repos/$GITHUB_REPOSITORY/contents/$CHANGELOG?ref=$TAG" --silent 2>&1 >/dev/null); then
62
+ [[ "$err" == *"HTTP 404"* ]] || { echo "::error::could not look up $CHANGELOG at $TAG: $err"; exit 1; }
63
+ echo "$TAG has no $CHANGELOG; linking the default branch's"
64
+ ref=HEAD
65
+ fi
66
+ # --verify-tag: refuse rather than create the tag. With --generate-notes, GitHub puts these notes
67
+ # before the generated ones.
68
+ gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$TAG" --generate-notes "${flags[@]}" \
69
+ --notes "What changed, and what to do when upgrading: [$CHANGELOG](https://github.com/$GITHUB_REPOSITORY/blob/$ref/$CHANGELOG)."
@@ -10,9 +10,11 @@
10
10
  # which rebuilds) resumes a release that stopped partway.
11
11
  # If the gem is pushed but the tag step keeps failing, push the tag by hand at the built commit: that
12
12
  # tag push's run skips the push (same sum).
13
+ # Then github-release.yml creates the GitHub release page for the tag (it can also be run by hand for
14
+ # an earlier tag).
13
15
  #
14
- # Dry run: tick dry_run to check the wiring. Everything runs except the changelog checks, the tag and
15
- # the push; the credential exchange fails unless the trusted publisher is set up.
16
+ # Dry run: tick dry_run to check the wiring. Everything runs except the changelog checks, the tag, the
17
+ # push and the release page; the credential exchange fails unless the trusted publisher is set up.
16
18
  #
17
19
  # The "release" environment's protection rules are the ONLY gate on publishing (required reviewer, no
18
20
  # admin bypass, deployments only from master and v*.*.* tags): RubyGems trusts any run of this file
@@ -208,3 +210,12 @@ jobs:
208
210
  else
209
211
  echo "::error::tag $TAG is not at $GITHUB_SHA (now: $tagged)"; exit 1
210
212
  fi
213
+
214
+ release-page:
215
+ needs: [build, publish]
216
+ if: ${{ !inputs.dry_run }}
217
+ permissions:
218
+ contents: write # github-release.yml creates the release
219
+ uses: ./.github/workflows/github-release.yml
220
+ with:
221
+ tag: ${{ needs.build.outputs.tag }}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 (2026-11-01)
4
+
5
+ Changes since 0.3.0:
6
+
7
+ - Fixed: the daemon writes its pid file to a new file in the same directory and renames it into place, so a
8
+ symlink or another file already at the pid file's path is replaced instead of written through, and a reader
9
+ never sees a half-written pid file. The pid file now always belongs to the daemon's user, with mode 0666 less
10
+ the umask; an existing pid file used to keep its owner and mode. In a directory the daemon's user cannot write
11
+ (a pid file an init script created and handed to that user, say), it writes the existing pid file in place as
12
+ before, except through a symlink. **What to do:** if another user's tool reads the pid file (a monitor such as
13
+ god), start the daemon with a umask that leaves it readable (022 gives 0644). (#31, @nesquena)
14
+ - Fixed: stopping (`-k`, `stop`) and `daemon_running?` ignore a pid file that another user could have written to
15
+ have the process it names stopped: a symlink, a file with other hard links, or a file owned by neither root, nor
16
+ the user stopping, nor any of the uids the process runs as. A stop then keeps the pid file and returns false,
17
+ saying "No <name> processes are running" for a symlink and why for the others (for one, "pid file X is owned by
18
+ uid A but process P belongs to uid B; not stopping it"), and a start goes ahead and replaces the pid file. The owner is checked through `/proc` (Linux); elsewhere only symlinks and
19
+ hard links are. **What to do:** nothing for pid files the daemon writes itself, including a daemon started with
20
+ `sudo ... -u nobody` and stopped with `sudo ... -k`; a pid file written by another tool must be a plain file
21
+ belonging to root or to the daemon's user. (#31, @nesquena)
22
+ - Fixed: the log file is no longer given mode 0644 at every start. An existing log file keeps its mode (through a
23
+ symlinked log path, so does the file it points to), and a new one is created with mode 0644 less the umask:
24
+ 0644 with the usual umask of 022, 0640 with 027. **What to do:** for a log file other users can read under a
25
+ stricter umask, create it with the mode you want before starting, or set the umask. (#31, @nesquena)
26
+ - New `:argv` option: `Dante::Runner.new(name, :argv => [...])`, `Dante.run(name, :argv => [...])` or
27
+ `execute(:argv => [...])` parses the options in that array instead of `ARGV`, so dante can run inside a program
28
+ that has options of its own (a test runner, for one) without failing on them; `:argv => []` parses nothing. The
29
+ array is not changed. Without `:argv`, `ARGV` is parsed as before. Reported by @neomantic-zz in #4.
30
+ (#33, @nesquena)
31
+ - Fixed: a daemon's standard input is now `/dev/null`. It stayed on the terminal or pipe it was started from, so a
32
+ daemon reading stdin read the user's keystrokes, and a terminal or pty that started it never saw end-of-file
33
+ while the daemon ran. (#30, @nesquena)
34
+ - Fixed: stopping (`-k`, `stop`) reads the pid file once, then signals and waits for that daemon, and removes the
35
+ pid file only if it still names it. Two stops at once no longer fail with `Errno::ENOENT`; a new daemon that
36
+ records its pid while an old one stops keeps its pid file, and `--force` no longer sends it SIGKILL. A pid file
37
+ path containing `[`, `*` or `?` can now be stopped. The `-k` help text now says what it does: it stops the daemon
38
+ in the pid file, and ignores its optional argument. (#30, @nesquena)
39
+ - Fixed: stopping checks whether the daemon has exited every 0.1 seconds instead of every second, so `-k` returns
40
+ as soon as the daemon has stopped rather than after a second at least. It still waits up to 5 seconds (and 5
41
+ more with `--force`). (#30, @nesquena)
42
+ - Fixed: `:kill => false` and `:restart => false` passed to `execute` are now off. Before, giving either key at
43
+ all stopped the daemon (`:kill`) or stopped and restarted it (`:restart`), whatever its value. `:kill => nil`, as
44
+ `-k` sets it, still stops. (#30, @nesquena)
45
+ - Fixed: `:daemonize => false` with a `:log_path` now writes the output to the log file, as leaving `:daemonize`
46
+ out does; before, the log path was ignored. (#30, @nesquena)
47
+ - Fixed: `Runner#restart` starts the service as a daemon when `:daemonize` is set, as `execute` does; it used to
48
+ run it in the foreground, in the calling process. **What to do:** to keep running the service in the calling
49
+ process, call `restart` on a runner without `:daemonize`. (#30, @nesquena)
50
+ - For subclasses of `Dante::Runner`: `stop` now reads the pid file and checks the process itself instead of calling
51
+ `daemon_running?` and `daemon_stopped?`, and the protected `kill_pid` and `wait_for_stop` now take the pid first
52
+ (`kill_pid(pid, k, sig)`, `wait_for_stop(pid, signalled)`). (#30, @nesquena)
53
+ - Documentation: the README's `-u nobody -G nogroup` example now gives pid and log paths that user can write (with
54
+ the defaults, the daemon failed to start), says what `-k` and `-f`/`--force` do, lists all the options, and adds
55
+ the requirements (MRI on Unix, for `fork`), running under systemd, and log rotation with `copytruncate` (SIGHUP
56
+ stops the daemon). (#30, @nesquena)
57
+ - The gemspec links the source code, this changelog and the issue tracker (shown on rubygems.org), and sets
58
+ `rubygems_mfa_required`, so pushes of the gem to rubygems.org now require MFA. (#30, @nesquena)
59
+
3
60
  ## 0.3.0 (2026-10-31)
4
61
 
5
62
  Changes since 0.2.0:
data/README.md CHANGED
@@ -10,6 +10,11 @@ can be started via command line or start/stop a daemon, and will store a pid fil
10
10
  If you need to create a ruby executable and you want standard daemon start/stop with pid files
11
11
  and no hassle, this gem will be a great way to get started.
12
12
 
13
+ ## Requirements
14
+
15
+ Dante daemonizes with `fork`, so it needs MRI (CRuby) 2.7 or newer on Unix: Linux, macOS or a BSD. It does not
16
+ run on Windows or JRuby, which have no `fork`.
17
+
13
18
  ## Installation
14
19
 
15
20
  Add to your Gemfile:
@@ -81,24 +86,54 @@ All stdout and stderr will be redirected to the specified logfile. If no logfile
81
86
  stdout and stderr will be directed to /var/log/<myapp name>.log.
82
87
 
83
88
  ```
84
- ./bin/myapp -d -u nobody -G nogroup
89
+ sudo install -d -o nobody -g nogroup /var/run/myapp /var/log/myapp
90
+ sudo ./bin/myapp -d -u nobody -G nogroup -P /var/run/myapp/myapp.pid -l /var/log/myapp/myapp.log
85
91
  ```
86
92
 
87
93
  will switch to the user nobody before starting, with the group given by `-G`. When started as root, the group
88
94
  defaults to the user's primary group, and the process takes on the user's supplementary groups (plus the `-G`
89
- group, if given).
95
+ group, if given). The switch happens before the pid file and the log file are written, so both must be in
96
+ directories that user can write: the defaults, `/var/run/myapp.pid` and `/var/log/myapp.log`, are not, and the
97
+ daemon would fail to start. Keep the pid file and the log in directories that only the daemon's user (or root) can
98
+ write, never in one that other users can write, such as `/tmp`: another user could put a file or a symlink of
99
+ their own at that path. On most Linux systems `/var/run` is emptied at boot, so create its directory at each boot
100
+ (systemd's `RuntimeDirectory=`, below, or a `tmpfiles.d` entry) rather than once.
90
101
 
91
102
  ```
92
103
  ./bin/myapp -k -P /var/run/myapp.pid
93
104
  ```
94
105
 
95
- will stop all daemonized processes for the specified pid file.
106
+ will stop the daemon whose pid is in the specified pid file: it sends SIGTERM, waits up to 5 seconds for the
107
+ daemon to exit, and then removes the pid file. If the daemon is still running, it says "Failed to kill daemonized
108
+ process" and keeps the pid file.
109
+
110
+ ```
111
+ ./bin/myapp -k -f -P /var/run/myapp.pid
112
+ ```
113
+
114
+ will do the same, and if the daemon survives SIGTERM, send it SIGKILL (`-f`/`--force`, or `:force => true`) and
115
+ wait up to 5 more seconds.
96
116
 
97
117
  ```
98
118
  ./bin/myapp --help
99
119
  ```
100
120
 
101
- Will return a useful help banner message explaining the simple usage.
121
+ Will return a useful help banner message explaining the simple usage. These are all the built-in options:
122
+
123
+ ```
124
+ -p, --port PORT Specify port
125
+ (default: )
126
+ -P, --pid FILE save PID in FILE when using -d option.
127
+ (default: /var/run/myapp.pid)
128
+ -d, --daemon Daemonize mode
129
+ -l, --log FILE Logfile for output
130
+ (default: /var/log/myapp.log)
131
+ -k, --kill [PORT] Stop the daemon in the pid file (PORT is ignored)
132
+ -u, --user USER User to run as
133
+ -G, --group GROUP Group to run as
134
+ -f, --force Force kill if SIGTERM fails
135
+ -?, --help Display this usage information.
136
+ ```
102
137
 
103
138
  ### Advanced
104
139
 
@@ -158,6 +193,83 @@ Dante::Runner.new('gitdocs').execute(:daemonize => true, :restart => true, :pid_
158
193
 
159
194
  so you can use dante as part of a more complex CLI executable.
160
195
 
196
+ ### Options from somewhere other than ARGV
197
+
198
+ `execute` parses the command-line options in `ARGV`. Inside a program that has options of its own (a test runner,
199
+ say, or a Rake task), pass dante the options to parse with `:argv` instead, to `Dante::Runner.new`, `Dante.run`
200
+ or `execute`:
201
+
202
+ ```ruby
203
+ # parses only these, and leaves ARGV alone
204
+ Dante::Runner.new('gitdocs', :argv => ['-d', '-P', @pid]).execute { something! }
205
+ # parses nothing
206
+ Dante::Runner.new('gitdocs').execute(:argv => [], :daemonize => true, :pid_path => @pid) { something! }
207
+ ```
208
+
209
+ Without `:argv`, ARGV is parsed as before, and the options dante knows are removed from it. The array given as
210
+ `:argv` is never changed.
211
+
212
+ ## systemd
213
+
214
+ Under systemd, let systemd run the process in the foreground (`Type=simple`), as the user it names, with the
215
+ output going to the journal:
216
+
217
+ ```ini
218
+ # /etc/systemd/system/myapp.service
219
+ [Unit]
220
+ Description=myapp
221
+ After=network.target
222
+
223
+ [Service]
224
+ Type=simple
225
+ User=myapp
226
+ Group=myapp
227
+ ExecStart=/path/to/myapp/bin/myapp -p 8080
228
+ Restart=on-failure
229
+
230
+ [Install]
231
+ WantedBy=multi-user.target
232
+ ```
233
+
234
+ systemd stops it with SIGTERM, which dante handles. To have dante daemonize instead, use `Type=forking` with the
235
+ pid file and the log in directories systemd creates for that user:
236
+
237
+ ```ini
238
+ [Service]
239
+ Type=forking
240
+ User=myapp
241
+ Group=myapp
242
+ RuntimeDirectory=myapp
243
+ LogsDirectory=myapp
244
+ PIDFile=/run/myapp/myapp.pid
245
+ ExecStart=/path/to/myapp/bin/myapp -d -p 8080 -P /run/myapp/myapp.pid -l /var/log/myapp/myapp.log
246
+ Restart=on-failure
247
+ ```
248
+
249
+ Use `User=` and `Group=` rather than `-u` and `-G` under systemd, so that the process starts as that user and the
250
+ directories systemd creates belong to it.
251
+
252
+ ## Log rotation
253
+
254
+ The daemon opens its log file once, for appending, and has no SIGHUP handler: SIGHUP stops it. So rotate the log
255
+ with logrotate's `copytruncate`, rather than a `postrotate` script that sends SIGHUP:
256
+
257
+ ```
258
+ # /etc/logrotate.d/myapp
259
+ /var/log/myapp/myapp.log {
260
+ weekly
261
+ rotate 4
262
+ compress
263
+ delaycompress
264
+ missingok
265
+ notifempty
266
+ copytruncate
267
+ }
268
+ ```
269
+
270
+ `copytruncate` copies the log and empties it in place, and the daemon carries on writing at the start of the
271
+ emptied file. Lines written between the copy and the truncation are lost.
272
+
161
273
  ## God
162
274
 
163
275
  Dante can be used well in conjunction with the excellent God process manager. Simply, use Dante to daemonize a process
data/dante.gemspec CHANGED
@@ -11,6 +11,12 @@ Gem::Specification.new do |s|
11
11
  s.summary = %q{Turn any process into a demon}
12
12
  s.description = %q{Turn any process into a demon.}
13
13
  s.license = "MIT"
14
+ s.metadata = {
15
+ "source_code_uri" => "https://github.com/nesquena/dante",
16
+ "changelog_uri" => "https://github.com/nesquena/dante/blob/master/CHANGELOG.md",
17
+ "bug_tracker_uri" => "https://github.com/nesquena/dante/issues",
18
+ "rubygems_mfa_required" => "true"
19
+ }
14
20
 
15
21
  s.files = `git ls-files`.split("\n")
16
22
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
data/lib/dante/runner.rb CHANGED
@@ -47,26 +47,29 @@ module Dante
47
47
  @with_options = block
48
48
  end
49
49
 
50
- # Executes the runner based on options
50
+ # Executes the runner based on options, after parsing the command-line options in
51
+ # options[:argv] (given to new or execute), or else in ARGV
51
52
  # @runner.execute
52
53
  # @runner.execute { ... }
53
54
  def execute(opts={}, &block)
55
+ options[:argv] = opts[:argv] if opts.key?(:argv) # needed before the rest of opts is merged
54
56
  parse_options
55
57
  self.options.merge!(opts)
56
58
 
57
59
  @verify_options_hook.call(self.options) if @verify_options_hook
58
60
 
59
- if options.include?(:kill)
61
+ # A key given as false is off; -k with no argument sets :kill to nil, which is on.
62
+ if options.fetch(:kill, false) != false
60
63
  self.stop
61
64
  else # create process
62
- self.stop if options.include?(:restart)
65
+ self.stop if options.fetch(:restart, false) != false
63
66
 
64
67
  # If a username, uid, groupname, or gid is passed,
65
68
  # drop privileges accordingly.
66
69
  switch_user(options[:user], options[:group]) if options[:user] || options[:group]
67
70
 
68
71
  @startup_command = block if block_given?
69
- options[:daemonize] ? daemonize : start
72
+ start_service
70
73
  end
71
74
  end
72
75
 
@@ -89,6 +92,7 @@ module Dante
89
92
  trap_signals
90
93
  store_pid(Process.pid)
91
94
  redirect_output!
95
+ reopen_stream(IO.for_fd(0, autoclose: false), File::NULL, 'r') # fd 0, whatever STDIN or $stdin is
92
96
  start
93
97
  end
94
98
  started = daemon_survives?(daemon)
@@ -130,26 +134,31 @@ module Dante
130
134
  def start
131
135
  log "Starting #{@name} service..."
132
136
 
133
- if log_path = options[:log_path] && options[:daemonize].nil?
134
- redirect_output!
135
- end
137
+ # A daemon has redirected its output already
138
+ redirect_output! if options[:log_path] && !options[:daemonize]
136
139
 
137
140
  trap_signals
138
141
 
139
142
  @startup_command.call(self.options) if @startup_command
140
143
  end
141
144
 
142
- # Stops a daemonized process
145
+ # Stops a daemonized process. The pid file is read once: the daemon it names then is
146
+ # the one signalled and waited for, whatever the pid file says by the time it stops.
143
147
  def stop(kill_arg=nil)
144
- if self.daemon_running?
145
- if wait_for_stop(kill_pid(kill_arg || options[:kill]))
146
- FileUtils.rm options[:pid_path] # Only kill if we stopped the daemon
148
+ pid, pid_file_stat = read_pid_file(options[:pid_path])
149
+ running = pid && process_running?(pid)
150
+ if running && (untrusted = untrusted_pid_file(pid, pid_file_stat))
151
+ log "#{untrusted}; not stopping it"
152
+ false
153
+ elsif running
154
+ if wait_for_stop(pid, kill_pid(pid, kill_arg || options[:kill]))
155
+ remove_pid_file(pid) # Only once the daemon has stopped
147
156
  log "Daemonized process killed after term."
148
157
  else
149
158
  log "Failed to kill daemonized process"
150
159
  if options[:force]
151
- if wait_for_stop(kill_pid(kill_arg || options[:kill], 'KILL'))
152
- FileUtils.rm options[:pid_path] # Only kill if we stopped the daemon
160
+ if wait_for_stop(pid, kill_pid(pid, kill_arg || options[:kill], 'KILL'))
161
+ remove_pid_file(pid) # Only once the daemon has stopped
153
162
  log "Daemonized process killed after kill."
154
163
  end
155
164
  end
@@ -162,7 +171,7 @@ module Dante
162
171
 
163
172
  def restart
164
173
  self.stop
165
- self.start
174
+ start_service
166
175
  end
167
176
 
168
177
  def interrupt
@@ -179,21 +188,22 @@ module Dante
179
188
  ! self.daemon_running?
180
189
  end
181
190
 
182
- # Returns running for the daemonized process
191
+ # Returns running for the daemonized process. A pid file that another user could have
192
+ # written (see untrusted_pid_file) names no daemon.
183
193
  # self.daemon_running?
184
194
  def daemon_running?
185
- return false unless File.exist?(options[:pid_path])
186
- return false unless pid = read_pid(options[:pid_path])
187
- Process.kill 0, pid
188
- true
189
- rescue Errno::EPERM # the process exists but belongs to another user
190
- true
191
- rescue Errno::ESRCH
192
- false
195
+ pid, pid_file_stat = read_pid_file(options[:pid_path])
196
+ return false unless pid
197
+ process_running?(pid) && !untrusted_pid_file(pid, pid_file_stat)
193
198
  end
194
199
 
195
200
  protected
196
201
 
202
+ # Starts the service: as a daemon with :daemonize, else in the foreground.
203
+ def start_service
204
+ options[:daemonize] ? daemonize : start
205
+ end
206
+
197
207
  def parse_options
198
208
  headline = [@name, @description].compact.join(" - ")
199
209
  OptionParser.new do |opts|
@@ -219,7 +229,7 @@ module Dante
219
229
  options[:log_path] = v
220
230
  end
221
231
 
222
- opts.on("-k", "--kill [PORT]", String, "Kill specified running daemons - leave blank to kill all.") do |v|
232
+ opts.on("-k", "--kill [PORT]", String, "Stop the daemon in the pid file (PORT is ignored)") do |v|
223
233
  options[:kill] = v
224
234
  end
225
235
 
@@ -242,7 +252,7 @@ module Dante
242
252
 
243
253
  # Load options specified through 'with_options'
244
254
  instance_exec(opts, &@with_options) if @with_options
245
- end.parse!
255
+ end.parse!(options[:argv] ? options[:argv].dup : ARGV) # a copy of the caller's array
246
256
  options
247
257
  end
248
258
 
@@ -293,9 +303,30 @@ module Dante
293
303
  }
294
304
  end
295
305
 
306
+ # Writes pid to a new file in the pid file's directory and renames it into place, so
307
+ # that whatever was at the pid file's path (a symlink, or another file) is replaced
308
+ # rather than written into, and a reader sees either the old pid file or the whole new
309
+ # one. In a directory the daemon cannot write, it writes the pid file there already.
296
310
  def store_pid(pid)
297
- FileUtils.mkdir_p(File.dirname(options[:pid_path]))
298
- File.open(options[:pid_path], 'w'){|f| f.write("#{pid}\n")}
311
+ pid_path = options[:pid_path]
312
+ FileUtils.mkdir_p(File.dirname(pid_path))
313
+ tmp_path = "#{pid_path}.#{pid}.tmp"
314
+ FileUtils.rm_f(tmp_path) # one left by a crash (a symlink is removed, not followed)
315
+ begin
316
+ # EXCL: never written through a file or symlink put there since
317
+ File.open(tmp_path, File::WRONLY | File::CREAT | File::EXCL) { |f| f.write("#{pid}\n") }
318
+ rescue Errno::EACCES
319
+ # A pid file made for the daemon's user in a directory it cannot write, as an init
320
+ # script might. Without CREAT, a missing one fails, as it did before.
321
+ # ceiling: not atomic, so a reader can see it half-written; giving the daemon's user
322
+ # the directory restores the rename.
323
+ tmp_path = nil
324
+ File.open(pid_path, File::WRONLY | File::TRUNC | File::NOFOLLOW) { |f| f.write("#{pid}\n") }
325
+ return
326
+ end
327
+ File.rename(tmp_path, pid_path)
328
+ ensure
329
+ FileUtils.rm_f(tmp_path) if tmp_path # left only when writing or renaming failed
299
330
  end
300
331
 
301
332
  # Whether daemon, a child of this process, is still running START_GRACE seconds after
@@ -320,38 +351,77 @@ module Dante
320
351
  end
321
352
 
322
353
  # Removes the pid file if it still holds pid (another start may have replaced it).
354
+ # ceiling: the read and the delete are two steps, so a daemon that writes its pid between them loses its
355
+ # pid file; a lock on the pid file (flock) would close that window
323
356
  def remove_pid_file(pid)
324
357
  File.delete(options[:pid_path]) if read_pid(options[:pid_path]) == pid
325
358
  rescue Errno::ENOENT
326
359
  end
327
360
 
328
- # The pid in pid_file, or nil when it holds none: a pid file left empty by a crash
329
- # between creating and writing it names no process (and pid 0 or a negative pid would
330
- # signal a whole process group), nor does a number too large for a pid_t.
361
+ # The pid in pid_file, or nil when there is none: no pid file, or one left empty by a
362
+ # crash between creating and writing it, names no process (and pid 0 or a negative pid
363
+ # would signal a whole process group), nor does a number too large for a pid_t.
331
364
  def read_pid(pid_file)
332
- pid = File.read(pid_file).to_i
333
- pid if pid > 0 && pid < 2**31
365
+ read_pid_file(pid_file)&.first
334
366
  end
335
367
 
336
- # Sends sig to the daemons, and returns whether it reached any of them.
337
- def kill_pid(k='*', sig='TERM')
338
- Dir[options[:pid_path]].count do |f|
339
- begin
340
- next false unless pid = read_pid(f)
341
- Process.kill(sig, pid)
342
- log "Sent SIG#{sig} to PID: #{pid} at #{f}"
343
- true
344
- rescue => e
345
- log "Failed to stop! #{k}: #{e}"
346
- false
347
- end
348
- end > 0
368
+ # The pid in pid_file, as read_pid gives it, and the pid file's File::Stat, both from
369
+ # the one file opened; nil without a pid. A symlink is not followed, and names no pid
370
+ # (a start replaces it).
371
+ def read_pid_file(pid_file)
372
+ return unless File.exist?(pid_file)
373
+ File.open(pid_file, File::RDONLY | File::NOFOLLOW) do |file|
374
+ pid = file.read.to_i
375
+ [pid, file.stat] if pid > 0 && pid < 2**31
376
+ end
377
+ rescue Errno::ENOENT, Errno::ELOOP # removed since the check, or a symlink
378
+ end
379
+
380
+ # Why the pid file, with File::Stat pid_file_stat, cannot be trusted to name pid, its
381
+ # daemon: it has other hard links (which keep the owner of the file they link to), or
382
+ # it is owned by neither root, nor a user that pid runs as (any of its uids), nor the
383
+ # user running this (whose own permissions are all a stop could use), so another user
384
+ # could have written it to have that process stopped. nil when it can be trusted, or
385
+ # when the owner of pid cannot be told (it has gone, say).
386
+ # ceiling: the owner of a process is read from /proc (Linux); elsewhere only the hard
387
+ # links are checked. `ps -o ruid=,uid= -p PID` would tell the owner on other Unixes.
388
+ def untrusted_pid_file(pid, pid_file_stat)
389
+ return "pid file #{options[:pid_path]} has other hard links" if pid_file_stat.nlink > 1
390
+ pid_file_uid = pid_file_stat.uid
391
+ return if pid_file_uid == 0 || pid_file_uid == Process.euid
392
+ uids = File.read("/proc/#{pid}/status")[/^Uid:(.*)$/, 1] or return # no Uid line
393
+ process_uids = uids.split.map { |uid| Integer(uid) } # real, effective, saved, filesystem
394
+ return if process_uids.include?(pid_file_uid)
395
+ "pid file #{options[:pid_path]} is owned by uid #{pid_file_uid} but process #{pid} belongs to uid #{process_uids.first}"
396
+ rescue SystemCallError # no /proc
397
+ end
398
+
399
+ # Whether pid is a running process, counting one that belongs to another user.
400
+ def process_running?(pid)
401
+ Process.kill 0, pid
402
+ true
403
+ rescue Errno::EPERM # the process exists but belongs to another user
404
+ true
405
+ rescue Errno::ESRCH
406
+ false
407
+ end
408
+
409
+ # Sends sig to pid, the daemon, and returns whether it reached it.
410
+ def kill_pid(pid, k, sig='TERM')
411
+ Process.kill(sig, pid)
412
+ log "Sent SIG#{sig} to PID: #{pid} at #{options[:pid_path]}"
413
+ true
414
+ rescue => e
415
+ log "Failed to stop! #{k}: #{e}"
416
+ false
349
417
  end
350
418
 
351
- # Waits for the daemon to stop once a signal reached it. When none did (the daemon
352
- # belongs to another user), it cannot stop, so this only checks once.
353
- def wait_for_stop(signalled)
354
- signalled ? until_true(MAX_START_TRIES) { self.daemon_stopped? } : self.daemon_stopped?
419
+ # Waits for pid, the daemon, to stop once a signal reached it, checking every 0.1
420
+ # seconds (a Rational step, so the steps add up to exactly MAX_START_TRIES seconds).
421
+ # When no signal reached it (the daemon belongs to another user), it cannot stop, so
422
+ # this only checks once.
423
+ def wait_for_stop(pid, signalled)
424
+ signalled ? until_true(MAX_START_TRIES, Rational(1, 10)) { !process_running?(pid) } : !process_running?(pid)
355
425
  end
356
426
 
357
427
  # Redirect output based on log settings (reopens stdout/stderr to specified logfile)
@@ -360,12 +430,10 @@ module Dante
360
430
  if log_path = options[:log_path]
361
431
  # if the log directory doesn't exist, create it
362
432
  FileUtils.mkdir_p File.dirname(options[:log_path]), :mode => 0755
363
- # touch the log file to create it
364
- FileUtils.touch log_path
365
- # Set permissions on the log file
366
- File.chmod(0644, log_path)
367
- # Reopen $stdout (NOT +STDOUT+) to start writing to the log file
368
- reopen_stream($stdout, log_path, 'a')
433
+ # Reopen $stdout (NOT +STDOUT+) to append to the log file, created with mode 0644
434
+ # (less the umask) if absent. An existing log file keeps its mode; a symlink is
435
+ # followed.
436
+ reopen_stream($stdout, log_path, 'a', 0644)
369
437
  # Redirect $stderr to $stdout
370
438
  $stderr.reopen $stdout
371
439
  $stdout.sync = true
@@ -379,11 +447,11 @@ module Dante
379
447
  log_path = options[:log_path] ? options[:log_path] : '/dev/null'
380
448
  end
381
449
 
382
- # Reopens stream onto path, opened with mode. Reopening onto an open File rather
383
- # than a path works whatever mode the stream itself was opened with (a socket, for
384
- # one, when run under Spring or Zeus).
385
- def reopen_stream(stream, path, mode)
386
- File.open(path, mode) { |file| stream.reopen(file) }
450
+ # Reopens stream onto path, opened with mode (and created with perm). Reopening onto
451
+ # an open File rather than a path works whatever mode the stream itself was opened
452
+ # with (a socket, for one, when run under Spring or Zeus).
453
+ def reopen_stream(stream, path, mode, perm=0666)
454
+ File.open(path, mode, perm) { |file| stream.reopen(file) }
387
455
  end
388
456
 
389
457
  # Runs until the block condition is met or the timeout_seconds is exceeded
data/lib/dante/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Dante
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
data/test/runner_test.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require 'stringio'
1
2
  require File.expand_path('../test_helper', __FILE__)
2
3
  require 'socket'
3
4
 
@@ -38,6 +39,20 @@ describe "dante runner" do
38
39
  end
39
40
  end # no daemonize
40
41
 
42
+ describe "with daemonize set to false and a log file" do
43
+ it "writes its output to the log file" do
44
+ logfile = tmp_path('dante-foreground.log')
45
+ capture_stdout {
46
+ pid = fork do
47
+ Dante::Runner.new('test-process', :daemonize => false, :log_path => logfile) { puts "Started" }.execute
48
+ exit!(0)
49
+ end
50
+ Process.wait(pid) }
51
+
52
+ assert_equal "Started\n", (File.read(logfile) if File.exist?(logfile))
53
+ end
54
+ end # daemonize false
55
+
41
56
  describe "with daemonize flag" do
42
57
  before do
43
58
  @process = TestingProcess.new('b', @tmpdir)
@@ -255,11 +270,82 @@ describe "dante runner" do
255
270
  assert_equal "640", "%o" % (File.stat(@created).mode & 0777)
256
271
  end
257
272
 
258
- it "still gives the log file mode 0644" do
259
- assert_equal 0644, File.stat(@logfile).mode & 0777
273
+ it "creates the log file with mode 0644 less the umask" do
274
+ assert_equal "640", "%o" % (File.stat(@logfile).mode & 0777)
260
275
  end
261
276
  end # umask
262
277
 
278
+ describe "with daemonize flag and something at the pid file's path already" do
279
+ before do
280
+ @pid_path = tmp_path('dante.pid')
281
+ @other = tmp_path('other-file')
282
+ File.write(@other, "not a pid\n")
283
+ end
284
+
285
+ # Starts a daemon on @pid_path and returns its pid.
286
+ def start_daemon
287
+ started = tmp_path('started')
288
+ capture_stdout {
289
+ Dante::Runner.new('test-process', :daemonize => true, :pid_path => @pid_path, :log_path => tmp_path('dante.log')) {
290
+ File.write(started, ""); sleep 100 }.execute }
291
+ wait_for_output(started)
292
+ daemon_pid(@pid_path)
293
+ end
294
+
295
+ it "replaces a symlink with the pid file instead of writing through it" do
296
+ File.symlink(@other, @pid_path)
297
+ pid = start_daemon
298
+
299
+ refute File.symlink?(@pid_path), "the pid file is still a symlink"
300
+ assert_equal "#{pid}\n", File.read(@pid_path)
301
+ assert_equal "not a pid\n", File.read(@other)
302
+ assert_empty Dir[File.join(@tmpdir, '*.tmp')], "temporary file left behind"
303
+ end
304
+
305
+ it "replaces a hard link to another file instead of writing into it" do
306
+ File.link(@other, @pid_path)
307
+ pid = start_daemon
308
+
309
+ assert_equal "#{pid}\n", File.read(@pid_path)
310
+ assert_equal "not a pid\n", File.read(@other)
311
+ end
312
+ end # pid file path taken
313
+
314
+ describe "with a log file there already" do
315
+ # Runs a runner in the foreground in a forked child, writing to log_path.
316
+ def run_with_log(log_path)
317
+ capture_stdout {
318
+ pid = fork do
319
+ Dante::Runner.new('test-process', :log_path => log_path) { puts "Started" }.execute
320
+ exit!(0)
321
+ end
322
+ Process.wait(pid) }
323
+ end
324
+
325
+ it "appends to it and keeps its mode" do
326
+ logfile = tmp_path('dante.log')
327
+ File.write(logfile, "earlier line\n")
328
+ File.chmod(0600, logfile)
329
+ run_with_log(logfile)
330
+
331
+ assert_equal "earlier line\nStarted\n", File.read(logfile)
332
+ assert_equal "600", "%o" % (File.stat(logfile).mode & 0777)
333
+ end
334
+
335
+ it "follows a symlink to it, and keeps the mode of the file it points to" do
336
+ target = tmp_path('target.log')
337
+ File.write(target, "")
338
+ File.chmod(0600, target)
339
+ logfile = tmp_path('dante.log')
340
+ File.symlink(target, logfile)
341
+ run_with_log(logfile)
342
+
343
+ assert File.symlink?(logfile)
344
+ assert_equal "Started\n", File.read(target)
345
+ assert_equal "600", "%o" % (File.stat(target).mode & 0777)
346
+ end
347
+ end # existing log file
348
+
263
349
  describe "with the standard streams connected to a socket" do
264
350
  # Runs the block in a forked child whose stdin, stdout and stderr are one end of a
265
351
  # socket pair, as under Spring or Zeus. Returns the child's exit status and the
@@ -313,6 +399,53 @@ describe "dante runner" do
313
399
  assert_equal 0644, File.stat(@logfile).mode & 0777
314
400
  end
315
401
 
402
+ it "gives the daemon /dev/null as its standard input" do
403
+ stdin_path = tmp_path('daemon-stdin')
404
+ status, error = run_with_socket_streams do
405
+ Dante::Runner.new('test-process', :daemonize => true, :pid_path => tmp_path('dante.pid'), :log_path => tmp_path('dante.log')) {
406
+ File.write("#{stdin_path}.tmp", null_device?($stdin) ? "/dev/null" : $stdin.stat.ftype)
407
+ File.rename("#{stdin_path}.tmp", stdin_path)
408
+ sleep 100 }.execute
409
+ end
410
+
411
+ assert_equal [0, ""], [status, error]
412
+ wait_for_output(stdin_path)
413
+ assert_equal "/dev/null", File.read(stdin_path)
414
+ end
415
+
416
+ it "gives the daemon /dev/null as its standard input when $stdin is not STDIN" do
417
+ stdin_path = tmp_path('daemon-stdin')
418
+ status, error = run_with_socket_streams do
419
+ $stdin = StringIO.new("typed")
420
+ started = Dante::Runner.new('test-process', :daemonize => true, :pid_path => tmp_path('dante.pid'), :log_path => tmp_path('dante.log')) {
421
+ File.write("#{stdin_path}.tmp", null_device?(STDIN) ? "/dev/null" : STDIN.stat.ftype)
422
+ File.rename("#{stdin_path}.tmp", stdin_path)
423
+ sleep 100 }.execute
424
+ raise "not started" unless started
425
+ end
426
+
427
+ assert_equal [0, ""], [status, error]
428
+ wait_for_output(stdin_path)
429
+ assert_equal "/dev/null", File.read(stdin_path)
430
+ end
431
+
432
+ it "gives the daemon /dev/null as its standard input when STDIN is closed" do
433
+ stdin_path = tmp_path('daemon-stdin')
434
+ status, error = run_with_socket_streams do
435
+ STDIN.close
436
+ started = Dante::Runner.new('test-process', :daemonize => true, :pid_path => tmp_path('dante.pid'), :log_path => tmp_path('dante.log')) {
437
+ fd0 = IO.for_fd(0, autoclose: false)
438
+ File.write("#{stdin_path}.tmp", null_device?(fd0) ? "/dev/null" : fd0.stat.ftype)
439
+ File.rename("#{stdin_path}.tmp", stdin_path)
440
+ sleep 100 }.execute
441
+ raise "not started" unless started
442
+ end
443
+
444
+ assert_equal [0, ""], [status, error]
445
+ wait_for_output(stdin_path)
446
+ assert_equal "/dev/null", File.read(stdin_path)
447
+ end
448
+
316
449
  it "redirects all three streams to /dev/null without a log file" do
317
450
  status, error = run_with_socket_streams do
318
451
  Dante::Runner.new('test-process', :log_path => false).send(:redirect_output!)
@@ -533,6 +666,35 @@ describe "dante runner" do
533
666
  assert process_alive?(new_pid)
534
667
  end
535
668
 
669
+ it "restarts the daemon as a daemon with #restart" do
670
+ ran_in = tmp_path('ran-in')
671
+ test_pid = Process.pid
672
+ runner = Dante::Runner.new('test-process', @run_options) {
673
+ File.write("#{ran_in}.tmp", Process.pid.to_s)
674
+ File.rename("#{ran_in}.tmp", ran_in)
675
+ sleep 100 unless Process.pid == test_pid } # returns if it runs in this process
676
+ result = nil
677
+ output = capture_stdout { result = runner.restart }
678
+
679
+ wait_for_output(ran_in)
680
+ refute_equal test_pid.to_s, File.read(ran_in), "the service ran in the caller"
681
+ assert_equal true, result
682
+ assert_match(/Daemonized process killed after term.*Daemon has started successfully/m, output)
683
+ refute process_alive?(@pid), "the first daemon is still running"
684
+ assert_equal daemon_pid(@pid_path).to_s, File.read(ran_in)
685
+ end
686
+
687
+ it "neither stops nor restarts the daemon with :kill or :restart given as false" do
688
+ [{ :kill => false }, { :restart => false }].each do |off|
689
+ output = capture_stdout {
690
+ Dante::Runner.new('test-process', @run_options.merge(off)) { sleep 100 }.execute }
691
+
692
+ assert_equal ["Process is already started\n"], output.lines.grep_v(/: warning: /), off.inspect
693
+ assert_equal "#{@pid}\n", File.read(@pid_path), off.inspect
694
+ assert process_alive?(@pid), off.inspect
695
+ end
696
+ end
697
+
536
698
  it "stops the daemon with -k and removes its pid file" do
537
699
  ARGV.replace(['-k', '-P', @pid_path])
538
700
  output = capture_stdout { Dante::Runner.new('test-process').execute }
@@ -544,6 +706,99 @@ describe "dante runner" do
544
706
  end
545
707
  end # controlling a running daemon
546
708
 
709
+ describe "with daemonize flag, stopping a daemon while its pid file changes" do
710
+ before do
711
+ @pid_path = tmp_path('dante.pid')
712
+ @ready = tmp_path('ready')
713
+ end
714
+
715
+ after do
716
+ if @replacement
717
+ Process.kill('KILL', @replacement)
718
+ Process.wait(@replacement)
719
+ end
720
+ end
721
+
722
+ # Starts a daemon that runs on_term when it gets SIGTERM, and returns its pid.
723
+ def start_daemon(pid_path = @pid_path, &on_term)
724
+ ready = @ready
725
+ capture_stdout {
726
+ Dante::Runner.new('test-process', :daemonize => true, :pid_path => pid_path, :log_path => tmp_path('dante.log')) {
727
+ trap('TERM', &on_term) if on_term
728
+ File.write(ready, "")
729
+ sleep 100 }.execute }
730
+ wait_for_output(ready)
731
+ daemon_pid(pid_path)
732
+ end
733
+
734
+ it "stops it from two stops at once, neither of them failing" do
735
+ pid = start_daemon { sleep 0.5; exit }
736
+ output = capture_stdout {
737
+ stoppers = 2.times.map { Thread.new { Dante::Runner.new('test-process', :pid_path => @pid_path).stop } }
738
+ stoppers.each { |t| t.report_on_exception = false }
739
+ stoppers.each(&:value) }
740
+
741
+ assert_equal 2, output.scan(/Daemonized process killed after term/).size, output
742
+ refute process_alive?(pid)
743
+ refute File.exist?(@pid_path), "pid file left behind"
744
+ end
745
+
746
+ it "keeps the pid file when a new daemon recorded its pid there meanwhile" do
747
+ replacement = @replacement = spawn('sleep', '60')
748
+ pid_path = @pid_path
749
+ pid = start_daemon { File.write(pid_path, "#{replacement}\n"); exit }
750
+ output = capture_stdout { Dante::Runner.new('test-process', :pid_path => @pid_path).stop }
751
+
752
+ assert_match(/Daemonized process killed after term/, output)
753
+ refute process_alive?(pid)
754
+ assert_equal "#{replacement}\n", File.read(@pid_path)
755
+ end
756
+
757
+ it "sends SIGKILL with :force to the daemon it signalled, not to a new one in the pid file" do
758
+ replacement = @replacement = spawn('sleep', '60')
759
+ pid_path = @pid_path
760
+ pid = start_daemon { File.write(pid_path, "#{replacement}\n") } # survives SIGTERM
761
+ output = capture_stdout { Dante::Runner.new('test-process', :pid_path => @pid_path, :force => true).stop }
762
+
763
+ assert_match(/Sent SIGKILL to PID: #{pid} /, output)
764
+ assert_match(/Daemonized process killed after kill/, output)
765
+ refute process_alive?(pid)
766
+ assert process_alive?(replacement), "the new daemon was killed"
767
+ assert_equal "#{replacement}\n", File.read(@pid_path)
768
+ end
769
+
770
+ it "returns soon after the daemon stops, well within the second it used to wait first" do
771
+ # A process of this test's that takes 0.2 seconds to stop on SIGTERM, reaped as soon
772
+ # as it exits (a daemon is reaped by init), stands in for the daemon.
773
+ script = "trap('TERM') { sleep 0.2; exit }; File.write(ARGV[0], ''); sleep 100"
774
+ pid = spawn(RbConfig.ruby, '-e', script, @ready)
775
+ reaper = Process.detach(pid)
776
+ wait_for_output(@ready)
777
+ File.write(@pid_path, "#{pid}\n")
778
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
779
+ output = capture_stdout { Dante::Runner.new('test-process', :pid_path => @pid_path).stop }
780
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
781
+
782
+ assert_match(/Daemonized process killed after term/, output)
783
+ assert_operator elapsed, :<, 0.8
784
+ ensure
785
+ if reaper && reaper.alive?
786
+ Process.kill('KILL', pid)
787
+ reaper.join
788
+ end
789
+ end
790
+
791
+ it "stops a daemon whose pid file path has glob characters in it" do
792
+ pid_path = tmp_path('run[1]/dante.pid')
793
+ pid = start_daemon(pid_path)
794
+ output = capture_stdout { Dante::Runner.new('test-process', :pid_path => pid_path).stop }
795
+
796
+ assert_match(/Daemonized process killed after term/, output)
797
+ refute process_alive?(pid)
798
+ refute File.exist?(pid_path), "pid file left behind"
799
+ end
800
+ end # pid file changes
801
+
547
802
  describe "with daemonize flag and no log path" do
548
803
  before do
549
804
  # Records the log path the daemon would redirect its output to, then sends the
@@ -746,6 +1001,41 @@ describe "dante runner" do
746
1001
  end
747
1002
  end # -p
748
1003
 
1004
+ describe "with the :argv option" do
1005
+ before do
1006
+ ARGV.replace(['--host-program-flag', 'file.rb']) # options dante does not know
1007
+ end
1008
+
1009
+ # The port the startup block of runner gets, and ARGV afterwards.
1010
+ def port_from(runner, opts = {})
1011
+ port = :not_run
1012
+ capture_stdout { runner.execute(opts) { |o| port = o[:port] } }
1013
+ [port, ARGV]
1014
+ end
1015
+
1016
+ it "parses the given options instead of ARGV, and leaves both arrays as they were" do
1017
+ argv = ['-p', '1234', 'extra'].freeze
1018
+
1019
+ assert_equal [1234, ['--host-program-flag', 'file.rb']], port_from(Dante::Runner.new('test-process', :argv => argv))
1020
+ assert_equal ['-p', '1234', 'extra'], argv
1021
+ end
1022
+
1023
+ it "parses nothing when given an empty array" do
1024
+ assert_equal [nil, ['--host-program-flag', 'file.rb']], port_from(Dante::Runner.new('test-process', :argv => []))
1025
+ end
1026
+
1027
+ it "takes the options to parse from execute too" do
1028
+ assert_equal [4321, ['--host-program-flag', 'file.rb']], port_from(Dante::Runner.new('test-process'), :argv => ['-p', '4321'])
1029
+ end
1030
+
1031
+ it "takes the options to parse from Dante.run" do
1032
+ port = :not_run
1033
+ capture_stdout { Dante.run('test-process', :argv => ['-p', '1234']) { |o| port = o[:port] } }
1034
+
1035
+ assert_equal 1234, port
1036
+ end
1037
+ end # :argv
1038
+
749
1039
  describe "with parsing options" do
750
1040
  before do
751
1041
  ARGV.replace(['-t test_text'])
@@ -191,4 +191,124 @@ describe "dante runner switching user and group" do
191
191
  assert_equal groups_of(member, passwd.gid), creds[:groups]
192
192
  assert_operator creds[:groups].size, :>, 1
193
193
  end
194
+
195
+ it "stops a daemon started with -u from root with -k" do
196
+ run_in_child do
197
+ ARGV.replace(['-d', '-P', @pid_path, '-l', @log_path, '-u', 'nobody'])
198
+ Dante::Runner.new('test-process').execute { sleep 100 }
199
+ end
200
+ pid = daemon_pid(@pid_path)
201
+ assert_equal @nobody.uid, File.stat(@pid_path).uid
202
+ ARGV.replace(['-k', '-P', @pid_path])
203
+ output = capture_stdout { Dante::Runner.new('test-process').execute }
204
+
205
+ assert_match(/Daemonized process killed after term/, output)
206
+ refute process_alive?(pid), "daemon still running after -k"
207
+ refute File.exist?(@pid_path), "pid file left behind after -k"
208
+ end
209
+
210
+ # A process that runs as the user uid until it is killed, reaped as soon as it exits
211
+ # (as init reaps a daemon). It returns once the process runs sleep: a signal that
212
+ # reaches the forked child before then is lost in the exec.
213
+ def spawn_as(uid)
214
+ pid = fork do
215
+ Process::UID.change_privilege(uid) unless uid == 0
216
+ exec('sleep', '60')
217
+ end
218
+ (@reapers ||= []) << Process.detach(pid)
219
+ assert wait_until { (File.read("/proc/#{pid}/comm") rescue nil) == "sleep\n" }, "sleep never ran"
220
+ pid
221
+ end
222
+
223
+ after do
224
+ Array(@reapers).each do |reaper|
225
+ begin
226
+ Process.kill('KILL', reaper.pid)
227
+ rescue Errno::ESRCH
228
+ end
229
+ reaper.join
230
+ end
231
+ end
232
+
233
+ it "does not stop a process of another user named by a pid file it does not own" do
234
+ other = spawn_as(0)
235
+ File.write(@pid_path, "#{other}\n")
236
+ File.chown(@nobody.uid, nil, @pid_path)
237
+ runner = Dante::Runner.new('test-process', :pid_path => @pid_path, :force => true)
238
+ result = :not_returned
239
+ output = capture_stdout { result = runner.stop }
240
+
241
+ assert process_alive?(other), "the other user's process was stopped"
242
+ assert_equal "pid file #{@pid_path} is owned by uid #{@nobody.uid} but process #{other} belongs to uid 0; not stopping it\n", output
243
+ assert_equal false, result
244
+ assert_equal false, runner.daemon_running?
245
+ assert_equal "#{other}\n", File.read(@pid_path)
246
+ end
247
+
248
+ it "does not follow a symlink at the pid file's path to a pid file of root's" do
249
+ other = spawn_as(0)
250
+ roots_pid_file = tmp_path('root-service.pid') # outside @dir, which nobody can write
251
+ File.write(roots_pid_file, "#{other}\n")
252
+ File.symlink(roots_pid_file, @pid_path)
253
+ File.lchown(@nobody.uid, nil, @pid_path)
254
+ runner = Dante::Runner.new('test-process', :pid_path => @pid_path, :force => true)
255
+ output = capture_stdout { runner.stop }
256
+
257
+ assert process_alive?(other), "the process named by the symlink's target was stopped"
258
+ assert_equal "No test-process processes are running\n", output
259
+ assert_equal false, runner.daemon_running?
260
+ assert File.symlink?(@pid_path)
261
+ end
262
+
263
+ it "does not stop a process named by a pid file with other hard links" do
264
+ other = spawn_as(0)
265
+ roots_pid_file = tmp_path('root-service.pid')
266
+ File.write(roots_pid_file, "#{other}\n")
267
+ File.link(roots_pid_file, @pid_path)
268
+ runner = Dante::Runner.new('test-process', :pid_path => @pid_path, :force => true)
269
+ output = capture_stdout { runner.stop }
270
+
271
+ assert process_alive?(other), "the process named by the hard-linked file was stopped"
272
+ assert_equal "pid file #{@pid_path} has other hard links; not stopping it\n", output
273
+ assert_equal false, runner.daemon_running?
274
+ end
275
+
276
+ it "counts a daemon whose effective uid owns the pid file as running" do
277
+ run_in_child do
278
+ Process::UID.eid = @nobody.uid # the real uid stays root's
279
+ Dante::Runner.new('test-process', :daemonize => true, :pid_path => @pid_path, :log_path => @log_path) { sleep 100 }.execute
280
+ end
281
+ daemon_pid(@pid_path)
282
+ assert_equal @nobody.uid, File.stat(@pid_path).uid
283
+
284
+ assert_equal true, Dante::Runner.new('test-process', :pid_path => @pid_path).daemon_running?
285
+ end
286
+
287
+ it "writes a pid file made for it in a directory it cannot write, and can be stopped" do
288
+ dir = tmp_path('run') # root's, mode 0755
289
+ Dir.mkdir(dir, 0755)
290
+ pid_path = File.join(dir, 'dante.pid')
291
+ File.write(pid_path, "")
292
+ File.chown(@nobody.uid, nil, pid_path)
293
+ run_in_child do
294
+ ARGV.replace(['-d', '-P', pid_path, '-l', @log_path, '-u', 'nobody'])
295
+ raise "not started" unless Dante::Runner.new('test-process').execute { sleep 100 }
296
+ end
297
+ pid = daemon_pid(pid_path)
298
+ assert_equal ['dante.pid'], Dir.children(dir)
299
+ ARGV.replace(['-k', '-P', pid_path])
300
+ output = capture_stdout { Dante::Runner.new('test-process').execute }
301
+
302
+ assert_match(/Daemonized process killed after term/, output)
303
+ refute process_alive?(pid), "daemon still running after -k"
304
+ end
305
+
306
+ it "stops a process of another user named by a pid file of root's" do
307
+ other = spawn_as(@nobody.uid)
308
+ File.write(@pid_path, "#{other}\n")
309
+ output = capture_stdout { Dante::Runner.new('test-process', :pid_path => @pid_path).stop }
310
+
311
+ assert_match(/Daemonized process killed after term/, output)
312
+ refute File.exist?(@pid_path), "pid file left behind"
313
+ end
194
314
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dante
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Esquenazi
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-31 00:00:00.000000000 Z
11
+ date: 2026-11-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -45,7 +45,9 @@ executables: []
45
45
  extensions: []
46
46
  extra_rdoc_files: []
47
47
  files:
48
+ - ".github/dependabot.yml"
48
49
  - ".github/workflows/ci.yml"
50
+ - ".github/workflows/github-release.yml"
49
51
  - ".github/workflows/release.yml"
50
52
  - ".gitignore"
51
53
  - CHANGELOG.md
@@ -64,7 +66,11 @@ files:
64
66
  homepage: https://github.com/nesquena/dante
65
67
  licenses:
66
68
  - MIT
67
- metadata: {}
69
+ metadata:
70
+ source_code_uri: https://github.com/nesquena/dante
71
+ changelog_uri: https://github.com/nesquena/dante/blob/master/CHANGELOG.md
72
+ bug_tracker_uri: https://github.com/nesquena/dante/issues
73
+ rubygems_mfa_required: 'true'
68
74
  post_install_message:
69
75
  rdoc_options: []
70
76
  require_paths: