Skip to content

Commit 3cf0ac4

Browse files
Initial implementation of puma-plugin-delayed_stop
Puma plugin that delays shutdown after a configurable signal (default SIGQUIT) to give container orchestrators time to drain traffic before connections close. Includes RSpec test suite with integration tests that boot a real Puma server, CI workflow for Ruby 3.0-3.3 x Puma 5-6, and a GitHub Actions release workflow for publishing to RubyGems.
0 parents  commit 3cf0ac4

17 files changed

Lines changed: 591 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
ruby-version: ["3.0", "3.1", "3.2", "3.3"]
16+
puma-version: ["5", "6"]
17+
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- name: Set up Ruby ${{ matrix.ruby-version }}
22+
uses: ruby/setup-ruby@v1
23+
with:
24+
ruby-version: ${{ matrix.ruby-version }}
25+
bundler-cache: true
26+
27+
- name: Override Puma version
28+
run: |
29+
echo "gem 'puma', '~> ${{ matrix.puma-version }}.0'" >> Gemfile
30+
bundle install
31+
32+
- name: Run tests
33+
run: bundle exec rspec

.github/workflows/release.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
jobs:
9+
publish:
10+
runs-on: ubuntu-latest
11+
permissions:
12+
contents: write
13+
id-token: write
14+
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- name: Set up Ruby
19+
uses: ruby/setup-ruby@v1
20+
with:
21+
ruby-version: "3.3"
22+
bundler-cache: true
23+
24+
- name: Build gem
25+
run: gem build puma-plugin-delayed_stop.gemspec
26+
27+
- name: Publish to RubyGems
28+
uses: rubygems/release-gem@v1
29+
30+
- name: Create GitHub Release
31+
env:
32+
GH_TOKEN: ${{ github.token }}
33+
run: gh release create "${{ github.ref_name }}" *.gem --generate-notes

.gitignore

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
*.gem
2+
*.rbc
3+
/.config
4+
/coverage/
5+
/InstalledFiles
6+
/pkg/
7+
/spec/reports/
8+
/spec/examples.txt
9+
/test/tmp/
10+
/test/version_tmp/
11+
/tmp/
12+
13+
## Documentation cache and generated files:
14+
/.yardoc/
15+
/_yardoc/
16+
/doc/
17+
/rdoc/
18+
19+
## Environment normalization:
20+
/.bundle/
21+
/vendor/bundle
22+
/lib/bundler/man/
23+
24+
# Gem-specific
25+
Gemfile.lock
26+
27+
# IDE
28+
.idea/
29+
.vscode/
30+
*.swp
31+
*.swo
32+
*~

.rspec

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
--require spec_helper
2+
--format documentation
3+
--color

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Changelog
2+
3+
## 0.1.0 (Unreleased)
4+
5+
- Initial release
6+
- Configurable signal via `PUMA_DELAYED_STOP_SIGNAL` (default: `QUIT`)
7+
- Configurable drain period via `PUMA_DELAYED_STOP_DRAIN_SECONDS` (default: `5`)

Gemfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
source "https://rubygems.org"
4+
5+
gemspec

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# The MIT License (MIT)
2+
3+
Copyright © 2026 The Regents of the University of California
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# puma-plugin-delayed_stop
2+
3+
A [Puma](https://puma.io) plugin that delays shutdown after receiving a signal, giving container orchestrators (Kubernetes, Docker Swarm, ECS, etc.) time to remove the instance from load balancing before connections are closed.
4+
5+
## The problem
6+
7+
When Puma receives `SIGTERM`, it begins shutting down immediately. In orchestrated environments, the termination signal often arrives *before* the orchestrator has finished removing the container from its service mesh or load balancer. Requests routed to the container during this window are dropped.
8+
9+
## The solution
10+
11+
This plugin intercepts a configurable signal (default: `SIGQUIT`) and waits a configurable number of seconds before telling Puma to stop. This gives the orchestrator time to update its routing tables.
12+
13+
A typical Kubernetes setup would configure the pod's `preStop` hook or `terminationGracePeriodSeconds` to send `SIGQUIT`, then later `SIGTERM`:
14+
15+
```yaml
16+
lifecycle:
17+
preStop:
18+
exec:
19+
command: ["kill", "-QUIT", "1"]
20+
```
21+
22+
## Installation
23+
24+
Add to your Gemfile:
25+
26+
```ruby
27+
gem "puma-plugin-delayed_stop"
28+
```
29+
30+
Then in your `config/puma.rb`:
31+
32+
```ruby
33+
plugin :delayed_stop
34+
```
35+
36+
## Configuration
37+
38+
Configuration is via environment variables:
39+
40+
| Variable | Default | Description |
41+
|---|---|---|
42+
| `PUMA_DELAYED_STOP_SIGNAL` | `QUIT` | Signal name (without `SIG` prefix) that triggers the delayed stop |
43+
| `PUMA_DELAYED_STOP_DRAIN_SECONDS` | `5` | Seconds to wait before telling Puma to stop |
44+
45+
**Warning:** Do not set `PUMA_DELAYED_STOP_SIGNAL` to a signal that Puma already handles (`TERM`, `INT`, `HUP`, `USR1`, `USR2`). Puma registers its own handlers for these signals *after* plugins start, so the plugin's handler will be silently overwritten. The default (`QUIT`) is safe because Puma does not trap it. See [Puma's signal handling documentation](https://github.com/puma/puma/blob/master/docs/signals.md) for the full list of reserved signals.
46+
47+
## How it works
48+
49+
1. On startup, the plugin registers a signal handler for the configured signal.
50+
2. When the signal is received, the handler sleeps for the configured drain period.
51+
3. After sleeping, it calls `launcher.stop`, which initiates Puma's normal graceful shutdown.
52+
53+
## Development
54+
55+
```bash
56+
bundle install
57+
bundle exec rspec
58+
```
59+
60+
## License
61+
62+
[MIT](LICENSE)

Rakefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
3+
require "bundler/gem_tasks"
4+
require "rspec/core/rake_task"
5+
6+
RSpec::Core::RakeTask.new(:spec)
7+
8+
task default: :spec

lib/puma-plugin-delayed_stop.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
# This file exists so that `require "puma-plugin-delayed_stop"` works, but
4+
# Puma discovers plugins via lib/puma/plugin/<name>.rb automatically.
5+
require_relative "puma/plugin/delayed_stop"

0 commit comments

Comments
 (0)