├── .editorconfig ├── .github └── workflows │ ├── documentation-coverage.yaml │ ├── documentation.yaml │ ├── rubocop.yaml │ ├── test-coverage.yaml │ ├── test-external.yaml │ └── test.yaml ├── .gitignore ├── .mailmap ├── .rubocop.yml ├── async-container.gemspec ├── bake.rb ├── config ├── external.yaml ├── metrics.rb └── sus.rb ├── examples ├── benchmark │ └── scalability.rb ├── channel.rb ├── container.rb ├── controller.rb ├── exec-child │ ├── jobs │ ├── readme.md │ ├── start │ └── web ├── fan-out │ └── pipe.rb ├── grace │ └── server.rb ├── health_check │ └── test.rb ├── http │ ├── client.rb │ └── server.rb ├── minimal.rb ├── puma │ ├── application.rb │ ├── config.ru │ ├── gems.rb │ ├── puma.rb │ └── readme.md └── queue │ └── server.rb ├── fixtures └── async │ └── container │ ├── a_container.rb │ ├── controllers.rb │ └── controllers │ ├── bad.rb │ ├── dots.rb │ ├── graceful.rb │ ├── notify.rb │ └── working_directory.rb ├── gems.rb ├── gems ├── async-head.rb └── async-v1.rb ├── guides └── getting-started │ └── readme.md ├── lib ├── async │ ├── container.rb │ └── container │ │ ├── best.rb │ │ ├── channel.rb │ │ ├── controller.rb │ │ ├── error.rb │ │ ├── forked.rb │ │ ├── generic.rb │ │ ├── group.rb │ │ ├── hybrid.rb │ │ ├── keyed.rb │ │ ├── notify.rb │ │ ├── notify │ │ ├── client.rb │ │ ├── console.rb │ │ ├── log.rb │ │ ├── pipe.rb │ │ ├── server.rb │ │ └── socket.rb │ │ ├── statistics.rb │ │ ├── threaded.rb │ │ └── version.rb └── metrics │ └── provider │ └── async │ ├── container.rb │ └── container │ └── generic.rb ├── license.md ├── readme.md ├── release.cert ├── releases.md └── test └── async ├── container.rb └── container ├── channel.rb ├── controller.rb ├── forked.rb ├── hybrid.rb ├── notify.rb ├── notify ├── log.rb ├── pipe.rb └── socket.rb ├── statistics.rb └── threaded.rb /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | [*.{yml,yaml}] 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.github/workflows/documentation-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | validate: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: "3.4" 21 | bundler-cache: true 22 | 23 | - name: Validate coverage 24 | timeout-minutes: 5 25 | run: bundle exec bake decode:index:coverage lib 26 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment: 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | env: 20 | CONSOLE_OUTPUT: XTerm 21 | BUNDLE_WITH: maintenance 22 | 23 | jobs: 24 | generate: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: "3.4" 33 | bundler-cache: true 34 | 35 | - name: Installing packages 36 | run: sudo apt-get install wget 37 | 38 | - name: Generate documentation 39 | timeout-minutes: 5 40 | run: bundle exec bake utopia:project:static --force no 41 | 42 | - name: Upload documentation artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: docs 46 | 47 | deploy: 48 | runs-on: ubuntu-latest 49 | 50 | environment: 51 | name: github-pages 52 | url: ${{steps.deployment.outputs.page_url}} 53 | 54 | needs: generate 55 | steps: 56 | - name: Deploy to GitHub Pages 57 | id: deployment 58 | uses: actions/deploy-pages@v4 59 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yaml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ruby 20 | bundler-cache: true 21 | 22 | - name: Run RuboCop 23 | timeout-minutes: 10 24 | run: bundle exec rubocop 25 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | test: 14 | name: ${{matrix.ruby}} on ${{matrix.os}} 15 | runs-on: ${{matrix.os}}-latest 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.4" 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{matrix.ruby}} 31 | bundler-cache: true 32 | 33 | - name: Run tests 34 | timeout-minutes: 5 35 | run: bundle exec bake test 36 | 37 | - uses: actions/upload-artifact@v4 38 | with: 39 | include-hidden-files: true 40 | if-no-files-found: error 41 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 42 | path: .covered.db 43 | 44 | validate: 45 | needs: test 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: ruby/setup-ruby@v1 51 | with: 52 | ruby-version: "3.4" 53 | bundler-cache: true 54 | 55 | - uses: actions/download-artifact@v4 56 | 57 | - name: Validate coverage 58 | timeout-minutes: 5 59 | run: bundle exec bake covered:validate --paths */.covered.db \; 60 | -------------------------------------------------------------------------------- /.github/workflows/test-external.yaml: -------------------------------------------------------------------------------- 1 | name: Test External 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | 16 | strategy: 17 | matrix: 18 | os: 19 | - ubuntu 20 | - macos 21 | 22 | ruby: 23 | - "3.1" 24 | - "3.2" 25 | - "3.3" 26 | - "3.4" 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: ${{matrix.ruby}} 33 | bundler-cache: true 34 | 35 | - name: Run tests 36 | timeout-minutes: 10 37 | run: bundle exec bake test:external 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | continue-on-error: ${{matrix.experimental}} 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.1" 25 | - "3.2" 26 | - "3.3" 27 | - "3.4" 28 | 29 | experimental: [false] 30 | 31 | include: 32 | - os: ubuntu 33 | ruby: truffleruby 34 | experimental: true 35 | - os: ubuntu 36 | ruby: jruby 37 | experimental: true 38 | - os: ubuntu 39 | ruby: head 40 | experimental: true 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: ruby/setup-ruby@v1 45 | with: 46 | ruby-version: ${{matrix.ruby}} 47 | bundler-cache: true 48 | 49 | - name: Run tests 50 | timeout-minutes: 10 51 | run: bundle exec bake test 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /pkg/ 3 | /gems.locked 4 | /.covered.db 5 | /external 6 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Yuji Yaginuma 2 | Juan Antonio Martín Lucas 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | 4 | Layout/IndentationStyle: 5 | Enabled: true 6 | EnforcedStyle: tabs 7 | 8 | Layout/InitialIndentation: 9 | Enabled: true 10 | 11 | Layout/IndentationWidth: 12 | Enabled: true 13 | Width: 1 14 | 15 | Layout/IndentationConsistency: 16 | Enabled: true 17 | EnforcedStyle: normal 18 | 19 | Layout/BlockAlignment: 20 | Enabled: true 21 | 22 | Layout/EndAlignment: 23 | Enabled: true 24 | EnforcedStyleAlignWith: start_of_line 25 | 26 | Layout/BeginEndAlignment: 27 | Enabled: true 28 | EnforcedStyleAlignWith: start_of_line 29 | 30 | Layout/ElseAlignment: 31 | Enabled: true 32 | 33 | Layout/DefEndAlignment: 34 | Enabled: true 35 | 36 | Layout/CaseIndentation: 37 | Enabled: true 38 | 39 | Layout/CommentIndentation: 40 | Enabled: true 41 | 42 | Layout/EmptyLinesAroundClassBody: 43 | Enabled: true 44 | 45 | Layout/EmptyLinesAroundModuleBody: 46 | Enabled: true 47 | 48 | Style/FrozenStringLiteralComment: 49 | Enabled: true 50 | 51 | Style/StringLiterals: 52 | Enabled: true 53 | EnforcedStyle: double_quotes 54 | -------------------------------------------------------------------------------- /async-container.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/async/container/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "async-container" 7 | spec.version = Async::Container::VERSION 8 | 9 | spec.summary = "Abstract container-based parallelism using threads and processes where appropriate." 10 | spec.authors = ["Samuel Williams", "Olle Jonsson", "Anton Sozontov", "Juan Antonio Martín Lucas", "Yuji Yaginuma"] 11 | spec.license = "MIT" 12 | 13 | spec.cert_chain = ["release.cert"] 14 | spec.signing_key = File.expand_path("~/.gem/release.pem") 15 | 16 | spec.homepage = "https://github.com/socketry/async-container" 17 | 18 | spec.metadata = { 19 | "documentation_uri" => "https://socketry.github.io/async-container/", 20 | "source_code_uri" => "https://github.com/socketry/async-container.git", 21 | } 22 | 23 | spec.files = Dir.glob(["{lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) 24 | 25 | spec.required_ruby_version = ">= 3.1" 26 | 27 | spec.add_dependency "async", "~> 2.22" 28 | end 29 | -------------------------------------------------------------------------------- /bake.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | # Update the project documentation with the new version number. 7 | # 8 | # @parameter version [String] The new version number. 9 | def after_gem_release_version_increment(version) 10 | context["releases:update"].call(version) 11 | context["utopia:project:readme:update"].call 12 | end 13 | -------------------------------------------------------------------------------- /config/external.yaml: -------------------------------------------------------------------------------- 1 | falcon: 2 | url: https://github.com/socketry/falcon.git 3 | command: bundle exec bake test 4 | async-service: 5 | url: https://github.com/socketry/async-service.git 6 | command: bundle exec bake test 7 | -------------------------------------------------------------------------------- /config/metrics.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | def prepare 7 | require "metrics/provider/async/container" 8 | end 9 | -------------------------------------------------------------------------------- /config/sus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2022-2024, by Samuel Williams. 5 | 6 | require "covered/sus" 7 | include Covered::Sus 8 | 9 | ENV["CONSOLE_LEVEL"] ||= "fatal" 10 | ENV["METRICS_BACKEND"] ||= "metrics/backend/test" 11 | 12 | def prepare_instrumentation! 13 | require "metrics" 14 | end 15 | 16 | def before_tests(...) 17 | prepare_instrumentation! 18 | 19 | super 20 | end 21 | -------------------------------------------------------------------------------- /examples/benchmark/scalability.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2022-2024, by Samuel Williams. 5 | 6 | # gem install async-container 7 | gem "async-container" 8 | 9 | require "async/clock" 10 | require_relative "../../lib/async/container" 11 | 12 | def fibonacci(n) 13 | if n < 2 14 | return n 15 | else 16 | return fibonacci(n-1) + fibonacci(n-2) 17 | end 18 | end 19 | 20 | require "sqlite3" 21 | 22 | def work(*) 23 | 512.times do 24 | File.read("/dev/zero", 1024*1024).bytesize 25 | end 26 | end 27 | 28 | def measure_work(container, **options, &block) 29 | duration = Async::Clock.measure do 30 | container.run(**options, &block) 31 | container.wait 32 | end 33 | 34 | puts "Duration for #{container.class}: #{duration}" 35 | end 36 | 37 | threaded = Async::Container::Threaded.new 38 | measure_work(threaded, count: 32, &self.method(:work)) 39 | 40 | forked = Async::Container::Forked.new 41 | measure_work(forked, count: 32, &self.method(:work)) 42 | 43 | hybrid = Async::Container::Hybrid.new 44 | measure_work(hybrid, count: 32, &self.method(:work)) 45 | -------------------------------------------------------------------------------- /examples/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | # Copyright, 2020, by Olle Jonsson. 6 | 7 | require "json" 8 | 9 | class Channel 10 | def initialize 11 | @in, @out = IO.pipe 12 | end 13 | 14 | def after_fork 15 | @out.close 16 | end 17 | 18 | def receive 19 | if data = @in.gets 20 | JSON.parse(data, symbolize_names: true) 21 | end 22 | end 23 | 24 | def send(**message) 25 | data = JSON.dump(message) 26 | 27 | @out.puts(data) 28 | end 29 | end 30 | 31 | status = Channel.new 32 | 33 | pid = fork do 34 | status.send(ready: false, status: "Initializing...") 35 | 36 | # exit(-1) # crash 37 | 38 | status.send(ready: true, status: "Initialization Complete!") 39 | end 40 | 41 | status.after_fork 42 | 43 | while message = status.receive 44 | pp message 45 | end 46 | 47 | pid, status = Process.waitpid2(pid) 48 | 49 | puts "Status: #{status}" 50 | -------------------------------------------------------------------------------- /examples/container.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2019-2024, by Samuel Williams. 6 | # Copyright, 2019, by Yuji Yaginuma. 7 | # Copyright, 2022, by Anton Sozontov. 8 | 9 | require "../lib/async/container" 10 | 11 | Console.logger.debug! 12 | 13 | container = Async::Container.new 14 | 15 | Console.debug "Spawning 2 containers..." 16 | 17 | 2.times do 18 | container.spawn do |task| 19 | Console.debug task, "Sleeping..." 20 | sleep(2) 21 | Console.debug task, "Waking up!" 22 | end 23 | end 24 | 25 | Console.debug "Waiting for container..." 26 | container.wait 27 | Console.debug "Finished." 28 | -------------------------------------------------------------------------------- /examples/controller.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2022, by Anton Sozontov. 6 | # Copyright, 2024, by Samuel Williams. 7 | 8 | require "../lib/async/container/controller" 9 | 10 | class Controller < Async::Container::Controller 11 | def setup(container) 12 | container.run(count: 1, restart: true) do |instance| 13 | if container.statistics.failed? 14 | Console.debug(self, "Child process restarted #{container.statistics.restarts} times.") 15 | else 16 | Console.debug(self, "Child process started.") 17 | end 18 | 19 | instance.ready! 20 | 21 | while true 22 | sleep 1 23 | 24 | Console.debug(self, "Work") 25 | 26 | if rand < 0.5 27 | Console.debug(self, "Should exit...") 28 | sleep 0.5 29 | exit(1) 30 | end 31 | end 32 | end 33 | end 34 | end 35 | 36 | Console.logger.debug! 37 | 38 | Console.debug(self, "Starting up...") 39 | 40 | controller = Controller.new 41 | 42 | controller.run 43 | -------------------------------------------------------------------------------- /examples/exec-child/jobs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "console" 5 | require "async/container/notify" 6 | 7 | # Console.logger.debug! 8 | 9 | class Jobs 10 | def self.start = self.new.start 11 | 12 | def start 13 | Console.info(self, "Starting jobs...") 14 | 15 | if notify = Async::Container::Notify.open! 16 | Console.info(self, "Notifying container ready...") 17 | notify.ready! 18 | end 19 | 20 | loop do 21 | Console.info(self, "Jobs running...") 22 | 23 | sleep 10 24 | end 25 | rescue Interrupt 26 | Console.info(self, "Exiting jobs...") 27 | end 28 | end 29 | 30 | Jobs.start 31 | -------------------------------------------------------------------------------- /examples/exec-child/readme.md: -------------------------------------------------------------------------------- 1 | # Exec Child Example 2 | 3 | This example demonstrates how to execute a child process using the `exec` function in a container. 4 | 5 | ## Usage 6 | 7 | Start the main controller: 8 | 9 | ``` 10 | > bundle exec ./start 11 | 0.0s info: AppController [oid=0x938] [ec=0x94c] [pid=96758] [2024-12-12 14:33:45 +1300] 12 | | Controller starting... 13 | 0.65s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96763] [2024-12-12 14:33:45 +1300] 14 | | Starting jobs... 15 | 0.65s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96763] [2024-12-12 14:33:45 +1300] 16 | | Notifying container ready... 17 | 0.65s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96763] [2024-12-12 14:33:45 +1300] 18 | | Jobs running... 19 | 0.65s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96760] [2024-12-12 14:33:45 +1300] 20 | | Starting web... 21 | 0.65s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96760] [2024-12-12 14:33:45 +1300] 22 | | Notifying container ready... 23 | 0.65s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96760] [2024-12-12 14:33:45 +1300] 24 | | Web running... 25 | 0.09s info: AppController [oid=0x938] [ec=0x94c] [pid=96758] [2024-12-12 14:33:45 +1300] 26 | | Controller started... 27 | ``` 28 | 29 | In another terminal: `kill -HUP 96758` to cause a blue-green restart, which causes a new container to be started with new jobs and web processes: 30 | 31 | ``` 32 | 9.57s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96836] [2024-12-12 14:33:54 +1300] 33 | | Starting jobs... 34 | 9.57s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96833] [2024-12-12 14:33:54 +1300] 35 | | Starting web... 36 | 9.57s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96836] [2024-12-12 14:33:54 +1300] 37 | | Notifying container ready... 38 | 9.57s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96833] [2024-12-12 14:33:54 +1300] 39 | | Notifying container ready... 40 | 9.57s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96836] [2024-12-12 14:33:54 +1300] 41 | | Jobs running... 42 | 9.57s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96833] [2024-12-12 14:33:54 +1300] 43 | | Web running... 44 | ``` 45 | 46 | Once the new container is running and the child processes have notified they are ready, the controller will stop the old container: 47 | 48 | ``` 49 | 9.01s info: Async::Container::Group [oid=0xa00] [ec=0x94c] [pid=96758] [2024-12-12 14:33:54 +1300] 50 | | Stopping all processes... 51 | | { 52 | | "timeout": true 53 | | } 54 | 9.01s info: Async::Container::Group [oid=0xa00] [ec=0x94c] [pid=96758] [2024-12-12 14:33:54 +1300] 55 | | Sending interrupt to 2 running processes... 56 | 9.57s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96760] [2024-12-12 14:33:54 +1300] 57 | | Exiting web... 58 | 9.57s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96763] [2024-12-12 14:33:54 +1300] 59 | | Exiting jobs... 60 | ``` 61 | 62 | The new container continues to run as expected: 63 | 64 | ``` 65 | 19.57s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96833] [2024-12-12 14:34:04 +1300] 66 | | Web running... 67 | 19.57s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96836] [2024-12-12 14:34:04 +1300] 68 | | Jobs running... 69 | ``` 70 | -------------------------------------------------------------------------------- /examples/exec-child/start: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "async/container" 5 | require "console" 6 | 7 | # Console.logger.debug! 8 | 9 | class AppController < Async::Container::Controller 10 | def setup(container) 11 | container.spawn(name: "Web") do |instance| 12 | # Specify ready: false here as the child process is expected to take care of the readiness notification: 13 | instance.exec("bundle", "exec", "web", ready: false) 14 | end 15 | 16 | container.spawn(name: "Jobs") do |instance| 17 | instance.exec("bundle", "exec", "jobs", ready: false) 18 | end 19 | end 20 | end 21 | 22 | controller = AppController.new 23 | 24 | controller.run 25 | -------------------------------------------------------------------------------- /examples/exec-child/web: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "console" 5 | require "async/container/notify" 6 | 7 | # Console.logger.debug! 8 | 9 | class Web 10 | def self.start = self.new.start 11 | 12 | def start 13 | Console.info(self, "Starting web...") 14 | 15 | if notify = Async::Container::Notify.open! 16 | Console.info(self, "Notifying container ready...") 17 | notify.ready! 18 | end 19 | 20 | loop do 21 | Console.info(self, "Web running...") 22 | 23 | sleep 10 24 | end 25 | rescue Interrupt 26 | Console.info(self, "Exiting web...") 27 | end 28 | end 29 | 30 | Web.start 31 | -------------------------------------------------------------------------------- /examples/fan-out/pipe.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2020-2024, by Samuel Williams. 6 | 7 | require "async/container" 8 | 9 | container = Async::Container.new 10 | input, output = IO.pipe 11 | 12 | container.async do |instance| 13 | output.close 14 | 15 | while message = input.gets 16 | puts "Hello World from #{instance}: #{message}" 17 | end 18 | 19 | puts "exiting" 20 | end 21 | 22 | 5.times do |i| 23 | output.puts "#{i}" 24 | end 25 | 26 | output.close 27 | 28 | container.wait 29 | -------------------------------------------------------------------------------- /examples/grace/server.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | require "../../lib/async/container" 8 | require "io/endpoint/host_endpoint" 9 | 10 | Console.logger.debug! 11 | 12 | module SignalWrapper 13 | def self.trap(signal, &block) 14 | signal = signal 15 | 16 | original = Signal.trap(signal) do 17 | ::Signal.trap(signal, original) 18 | block.call 19 | end 20 | end 21 | end 22 | 23 | class Controller < Async::Container::Controller 24 | def initialize(...) 25 | super 26 | 27 | @endpoint = ::IO::Endpoint.tcp("localhost", 8080) 28 | @bound_endpoint = nil 29 | end 30 | 31 | def start 32 | Console.debug(self) {"Binding to #{@endpoint}"} 33 | @bound_endpoint = Sync{@endpoint.bound} 34 | 35 | super 36 | end 37 | 38 | def setup(container) 39 | container.run count: 2, restart: true do |instance| 40 | SignalWrapper.trap(:INT) do 41 | Console.debug(self) {"Closing bound instance..."} 42 | @bound_endpoint.close 43 | end 44 | 45 | Sync do |task| 46 | Console.info(self) {"Starting bound instance..."} 47 | 48 | instance.ready! 49 | 50 | @bound_endpoint.accept do |peer| 51 | while true 52 | peer.write("#{Time.now.to_s.rjust(32)}: Hello World\n") 53 | sleep 1 54 | end 55 | end 56 | end 57 | end 58 | end 59 | 60 | def stop(graceful = true) 61 | super 62 | 63 | if @bound_endpoint 64 | @bound_endpoint.close 65 | @bound_endpoint = nil 66 | end 67 | end 68 | end 69 | 70 | controller = Controller.new 71 | 72 | controller.run 73 | -------------------------------------------------------------------------------- /examples/health_check/test.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2022, by Anton Sozontov. 6 | # Copyright, 2024, by Samuel Williams. 7 | 8 | require "metrics" 9 | require_relative "../../lib/async/container/controller" 10 | 11 | NAMES = [ 12 | "Cupcake", "Donut", "Eclair", "Froyo", "Gingerbread", "Honeycomb", "Ice Cream Sandwich", "Jelly Bean", "KitKat", "Lollipop", "Marshmallow", "Nougat", "Oreo", "Pie", "Apple Tart" 13 | ] 14 | 15 | class Controller < Async::Container::Controller 16 | def setup(container) 17 | container.run(count: 10, restart: true, health_check_timeout: 1) do |instance| 18 | if container.statistics.failed? 19 | Console.debug(self, "Child process restarted #{container.statistics.restarts} times.") 20 | else 21 | Console.debug(self, "Child process started.") 22 | end 23 | 24 | instance.name = NAMES.sample 25 | 26 | instance.ready! 27 | 28 | while true 29 | # Must update status more frequently than health check timeout... 30 | sleep(rand*1.2) 31 | 32 | instance.ready! 33 | end 34 | end 35 | end 36 | end 37 | 38 | controller = Controller.new # (container_class: Async::Container::Threaded) 39 | 40 | controller.run 41 | -------------------------------------------------------------------------------- /examples/http/client.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2022-2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/http/endpoint" 9 | require "async/http/client" 10 | 11 | endpoint = Async::HTTP::Endpoint.parse("http://localhost:9292") 12 | 13 | Async do 14 | client = Async::HTTP::Client.new(endpoint) 15 | 16 | response = client.get("/") 17 | puts response.read 18 | ensure 19 | client&.close 20 | end 21 | -------------------------------------------------------------------------------- /examples/http/server.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2022-2024, by Samuel Williams. 6 | 7 | require "async/container" 8 | 9 | require "async/http/endpoint" 10 | require "async/http/server" 11 | 12 | container = Async::Container::Forked.new 13 | 14 | endpoint = Async::HTTP::Endpoint.parse("http://localhost:9292") 15 | bound_endpoint = Sync{endpoint.bound} 16 | 17 | Console.info(endpoint) {"Bound to #{bound_endpoint.inspect}"} 18 | 19 | GC.start 20 | GC.compact if GC.respond_to?(:compact) 21 | 22 | container.run(count: 16, restart: true) do 23 | Async do |task| 24 | server = Async::HTTP::Server.for(bound_endpoint, protocol: endpoint.protocol, scheme: endpoint.scheme) do |request| 25 | Protocol::HTTP::Response[200, {}, ["Hello World"]] 26 | end 27 | 28 | Console.info(server) {"Starting server..."} 29 | 30 | server.run 31 | 32 | task.children.each(&:wait) 33 | end 34 | end 35 | 36 | container.wait 37 | -------------------------------------------------------------------------------- /examples/minimal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | # Copyright, 2020, by Olle Jonsson. 6 | 7 | class Threaded 8 | def initialize(&block) 9 | @channel = Channel.new 10 | @thread = Thread.new(&block) 11 | 12 | @waiter = Thread.new do 13 | begin 14 | @thread.join 15 | rescue Exception => error 16 | finished(error) 17 | else 18 | finished 19 | end 20 | end 21 | end 22 | 23 | attr :channel 24 | 25 | def close 26 | self.terminate! 27 | self.wait 28 | ensure 29 | @channel.close 30 | end 31 | 32 | def interrupt! 33 | @thread.raise(Interrupt) 34 | end 35 | 36 | def terminate! 37 | @thread.raise(Terminate) 38 | end 39 | 40 | def wait 41 | if @waiter 42 | @waiter.join 43 | @waiter = nil 44 | end 45 | 46 | @status 47 | end 48 | 49 | protected 50 | 51 | def finished(error = nil) 52 | @status = Status.new(error) 53 | @channel.out.close 54 | end 55 | end 56 | 57 | class Forked 58 | def initialize(&block) 59 | @channel = Channel.new 60 | @status = nil 61 | 62 | @pid = Process.fork do 63 | Signal.trap(:INT) {::Thread.current.raise(Interrupt)} 64 | Signal.trap(:TERM) {::Thread.current.raise(Terminate)} 65 | 66 | @channel.in.close 67 | 68 | yield 69 | end 70 | 71 | @channel.out.close 72 | end 73 | 74 | attr :channel 75 | 76 | def close 77 | self.terminate! 78 | self.wait 79 | ensure 80 | @channel.close 81 | end 82 | 83 | def interrupt! 84 | Process.kill(:INT, @pid) 85 | end 86 | 87 | def terminate! 88 | Process.kill(:TERM, @pid) 89 | end 90 | 91 | def wait 92 | unless @status 93 | _pid, @status = ::Process.wait(@pid) 94 | end 95 | 96 | @status 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /examples/puma/application.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024-2025, by Samuel Williams. 6 | 7 | require "async/container" 8 | require "console" 9 | 10 | require "io/endpoint/host_endpoint" 11 | require "io/endpoint/bound_endpoint" 12 | 13 | # Console.logger.debug! 14 | 15 | class Application < Async::Container::Controller 16 | def endpoint 17 | IO::Endpoint.tcp("0.0.0.0", 9292) 18 | end 19 | 20 | def bound_socket 21 | bound = endpoint.bound 22 | 23 | bound.sockets.each do |socket| 24 | socket.listen(Socket::SOMAXCONN) 25 | end 26 | 27 | return bound 28 | end 29 | 30 | def setup(container) 31 | @bound = bound_socket 32 | 33 | container.spawn(name: "Web", restart: true) do |instance| 34 | env = ENV.to_h 35 | 36 | @bound.sockets.each_with_index do |socket, index| 37 | env["PUMA_INHERIT_#{index}"] = "#{socket.fileno}:tcp://0.0.0.0:9292" 38 | end 39 | 40 | instance.exec(env, "bundle", "exec", "puma", "-C", "puma.rb", ready: false) 41 | end 42 | end 43 | end 44 | 45 | application = Application.new 46 | application.run 47 | -------------------------------------------------------------------------------- /examples/puma/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | run do |env| 4 | [200, {"content-type" => "text/plain"}, ["Hello World #{Time.now}"]] 5 | end 6 | -------------------------------------------------------------------------------- /examples/puma/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024-2025, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gem "async-container", path: "../.." 9 | gem "io-endpoint" 10 | 11 | gem "puma" 12 | gem "rack", "~> 3" 13 | -------------------------------------------------------------------------------- /examples/puma/puma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024-2025, by Samuel Williams. 5 | 6 | on_booted do 7 | require "async/container/notify" 8 | 9 | notify = Async::Container::Notify.open! 10 | notify&.ready! 11 | end 12 | -------------------------------------------------------------------------------- /examples/puma/readme.md: -------------------------------------------------------------------------------- 1 | # Puma Example 2 | 3 | This example shows how to start Puma in a container, using `on_boot` for process readiness. 4 | 5 | ## Usage 6 | 7 | ``` 8 | > bundle exec ./application.rb 9 | 0.0s info: Async::Container::Notify::Console [oid=0x474] [ec=0x488] [pid=196250] [2024-12-22 16:53:08 +1300] 10 | | {:status=>"Initializing..."} 11 | 0.0s info: Application [oid=0x4b0] [ec=0x488] [pid=196250] [2024-12-22 16:53:08 +1300] 12 | | Controller starting... 13 | Puma starting in single mode... 14 | * Puma version: 6.5.0 ("Sky's Version") 15 | * Ruby version: ruby 3.3.6 (2024-11-05 revision 75015d4c1f) [x86_64-linux] 16 | * Min threads: 0 17 | * Max threads: 5 18 | * Environment: development 19 | * PID: 196252 20 | * Listening on http://0.0.0.0:9292 21 | Use Ctrl-C to stop 22 | 0.12s info: Async::Container::Notify::Console [oid=0x474] [ec=0x488] [pid=196250] [2024-12-22 16:53:08 +1300] 23 | | {:ready=>true} 24 | 0.12s info: Application [oid=0x4b0] [ec=0x488] [pid=196250] [2024-12-22 16:53:08 +1300] 25 | | Controller started... 26 | ^C21.62s info: Async::Container::Group [oid=0x4ec] [ec=0x488] [pid=196250] [2024-12-22 16:53:30 +1300] 27 | | Stopping all processes... 28 | | { 29 | | "timeout": true 30 | | } 31 | 21.62s info: Async::Container::Group [oid=0x4ec] [ec=0x488] [pid=196250] [2024-12-22 16:53:30 +1300] 32 | | Sending interrupt to 1 running processes... 33 | - Gracefully stopping, waiting for requests to finish 34 | === puma shutdown: 2024-12-22 16:53:30 +1300 === 35 | - Goodbye! 36 | ``` 37 | -------------------------------------------------------------------------------- /examples/queue/server.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2020-2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/container" 9 | require "io/endpoint" 10 | require "io/endpoint/unix_endpoint" 11 | require "msgpack" 12 | 13 | class Wrapper < MessagePack::Factory 14 | def initialize 15 | super() 16 | 17 | # self.register_type(0x00, Object, packer: @bus.method(:temporary), unpacker: @bus.method(:[])) 18 | 19 | self.register_type(0x01, Symbol) 20 | self.register_type(0x02, Exception, 21 | packer: ->(exception){Marshal.dump(exception)}, 22 | unpacker: ->(data){Marshal.load(data)}, 23 | ) 24 | 25 | self.register_type(0x03, Class, 26 | packer: ->(klass){Marshal.dump(klass)}, 27 | unpacker: ->(data){Marshal.load(data)}, 28 | ) 29 | end 30 | end 31 | 32 | endpoint = IO::Endpoint.unix("test.ipc") 33 | bound_endpoint = endpoint.bound 34 | 35 | wrapper = Wrapper.new 36 | 37 | container = Async::Container.new 38 | 39 | container.spawn do |instance| 40 | Async do 41 | queue = 500_000.times.to_a 42 | Console.info(self) {"Hosting the queue..."} 43 | 44 | instance.ready! 45 | 46 | bound_endpoint.accept do |peer| 47 | Console.info(self) {"Incoming connection from #{peer}..."} 48 | 49 | packer = wrapper.packer(peer) 50 | unpacker = wrapper.unpacker(peer) 51 | 52 | unpacker.each do |message| 53 | command, *arguments = message 54 | 55 | case command 56 | when :ready 57 | if job = queue.pop 58 | packer.write([:job, job]) 59 | packer.flush 60 | else 61 | peer.close_write 62 | break 63 | end 64 | when :status 65 | Console.info("Job Status") {arguments} 66 | else 67 | Console.warn(self) {"Unhandled command: #{command}#{arguments.inspect}"} 68 | end 69 | end 70 | end 71 | end 72 | end 73 | 74 | container.run do |instance| 75 | Async do |task| 76 | endpoint.connect do |peer| 77 | instance.ready! 78 | 79 | packer = wrapper.packer(peer) 80 | unpacker = wrapper.unpacker(peer) 81 | 82 | packer.write([:ready]) 83 | packer.flush 84 | 85 | unpacker.each do |message| 86 | command, *arguments = message 87 | 88 | case command 89 | when :job 90 | # task.sleep(*arguments) 91 | packer.write([:status, *arguments]) 92 | packer.write([:ready]) 93 | packer.flush 94 | else 95 | Console.warn(self) {"Unhandled command: #{command}#{arguments.inspect}"} 96 | end 97 | end 98 | end 99 | end 100 | end 101 | 102 | container.wait 103 | 104 | Console.info(self) {"Done!"} 105 | -------------------------------------------------------------------------------- /fixtures/async/container/a_container.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require "async" 7 | 8 | module Async 9 | module Container 10 | AContainer = Sus::Shared("a container") do 11 | let(:container) {subject.new} 12 | 13 | with "#run" do 14 | it "can run several instances concurrently" do 15 | container.run do 16 | sleep(1) 17 | end 18 | 19 | expect(container).to be(:running?) 20 | 21 | container.stop(true) 22 | 23 | expect(container).not.to be(:running?) 24 | end 25 | 26 | it "can stop an uncooperative child process" do 27 | container.run do 28 | while true 29 | begin 30 | sleep(1) 31 | rescue Interrupt 32 | # Ignore. 33 | end 34 | end 35 | end 36 | 37 | expect(container).to be(:running?) 38 | 39 | # TODO Investigate why without this, the interrupt can occur before the process is sleeping... 40 | sleep 0.001 41 | 42 | container.stop(true) 43 | 44 | expect(container).not.to be(:running?) 45 | end 46 | end 47 | 48 | with "#async" do 49 | it "can run concurrently" do 50 | input, output = IO.pipe 51 | 52 | container.async do 53 | output.write "Hello World" 54 | end 55 | 56 | container.wait 57 | 58 | output.close 59 | expect(input.read).to be == "Hello World" 60 | end 61 | 62 | it "can run concurrently" do 63 | container.async(name: "Sleepy Jerry") do |task, instance| 64 | 3.times do |i| 65 | instance.name = "Counting Sheep #{i}" 66 | 67 | sleep 0.01 68 | end 69 | end 70 | 71 | container.wait 72 | end 73 | end 74 | 75 | it "should be blocking" do 76 | skip "Fiber.blocking? is not supported!" unless Fiber.respond_to?(:blocking?) 77 | 78 | input, output = IO.pipe 79 | 80 | container.spawn do 81 | output.write(Fiber.blocking? != false) 82 | end 83 | 84 | container.wait 85 | 86 | output.close 87 | expect(input.read).to be == "true" 88 | end 89 | 90 | with "instance" do 91 | it "can generate JSON representation" do 92 | IO.pipe do |input, output| 93 | container.spawn do |instance| 94 | output.write(instance.to_json) 95 | end 96 | 97 | container.wait 98 | 99 | expect(container.statistics).to have_attributes(failures: be == 0) 100 | 101 | output.close 102 | instance = JSON.parse(input.read, symbolize_names: true) 103 | expect(instance).to have_keys( 104 | process_id: be_a(Integer), 105 | name: be_a(String), 106 | ) 107 | end 108 | end 109 | end 110 | 111 | with "#sleep" do 112 | it "can sleep for a short time" do 113 | container.spawn do 114 | sleep(0.01) 115 | raise "Boom" 116 | end 117 | 118 | expect(container.statistics).to have_attributes(failures: be == 0) 119 | 120 | container.wait 121 | 122 | expect(container.statistics).to have_attributes(failures: be == 1) 123 | end 124 | end 125 | 126 | with "#stop" do 127 | it "can gracefully stop the child process" do 128 | container.spawn do 129 | sleep(1) 130 | rescue Interrupt 131 | # Ignore. 132 | end 133 | 134 | expect(container).to be(:running?) 135 | 136 | # See above. 137 | sleep 0.001 138 | 139 | container.stop(true) 140 | 141 | expect(container).not.to be(:running?) 142 | end 143 | 144 | it "can forcefully stop the child process" do 145 | container.spawn do 146 | sleep(1) 147 | rescue Interrupt 148 | # Ignore. 149 | end 150 | 151 | expect(container).to be(:running?) 152 | 153 | # See above. 154 | sleep 0.001 155 | 156 | container.stop(false) 157 | 158 | expect(container).not.to be(:running?) 159 | end 160 | 161 | it "can stop an uncooperative child process" do 162 | container.spawn do 163 | while true 164 | begin 165 | sleep(1) 166 | rescue Interrupt 167 | # Ignore. 168 | end 169 | end 170 | end 171 | 172 | expect(container).to be(:running?) 173 | 174 | # See above. 175 | sleep 0.001 176 | 177 | container.stop(true) 178 | 179 | expect(container).not.to be(:running?) 180 | end 181 | end 182 | 183 | with "#ready" do 184 | it "can notify the ready pipe in an asynchronous context" do 185 | container.run do |instance| 186 | Async do 187 | instance.ready! 188 | end 189 | end 190 | 191 | expect(container).to be(:running?) 192 | 193 | container.wait 194 | 195 | container.stop 196 | 197 | expect(container).not.to be(:running?) 198 | end 199 | end 200 | 201 | with "health_check_timeout:" do 202 | let(:container) {subject.new(health_check_interval: 1.0)} 203 | 204 | it "should not terminate a child process if it updates its state within the specified time" do 205 | # We use #run here to hit the Hybrid container code path: 206 | container.run(count: 1, health_check_timeout: 1.0) do |instance| 207 | instance.ready! 208 | 209 | 10.times do 210 | instance.ready! 211 | sleep(0.5) 212 | end 213 | end 214 | 215 | container.wait 216 | 217 | expect(container.statistics).to have_attributes(failures: be == 0) 218 | end 219 | 220 | it "can terminate a child process if it does not update its state within the specified time" do 221 | container.spawn(health_check_timeout: 1.0) do |instance| 222 | instance.ready! 223 | 224 | # This should trigger the health check - since restart is false, the process will be terminated: 225 | sleep 226 | end 227 | 228 | container.wait 229 | 230 | expect(container.statistics).to have_attributes(failures: be > 0) 231 | end 232 | 233 | it "can kill a child process even if it ignores exceptions/signals" do 234 | container.spawn(health_check_timeout: 1.0) do |instance| 235 | while true 236 | begin 237 | sleep 1 238 | rescue Exception => error 239 | # Ignore. 240 | end 241 | end 242 | end 243 | 244 | container.wait 245 | 246 | expect(container.statistics).to have_attributes(failures: be > 0) 247 | end 248 | end 249 | end 250 | end 251 | end 252 | -------------------------------------------------------------------------------- /fixtures/async/container/controllers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Async 3 | module Container 4 | module Controllers 5 | ROOT = File.join(__dir__, "controllers") 6 | 7 | def self.path_for(controller) 8 | File.join(ROOT, "#{controller}.rb") 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /fixtures/async/container/controllers/bad.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | require_relative "../../../../lib/async/container/controller" 8 | 9 | $stdout.sync = true 10 | 11 | class Bad < Async::Container::Controller 12 | def setup(container) 13 | container.run(name: "bad", count: 1, restart: true) do |instance| 14 | # Deliberately missing call to `instance.ready!`: 15 | # instance.ready! 16 | 17 | $stdout.puts "Ready..." 18 | 19 | sleep 20 | ensure 21 | $stdout.puts "Exiting..." 22 | end 23 | end 24 | end 25 | 26 | controller = Bad.new 27 | 28 | controller.run 29 | -------------------------------------------------------------------------------- /fixtures/async/container/controllers/dots.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2020-2025, by Samuel Williams. 6 | 7 | require_relative "../../../../lib/async/container/controller" 8 | 9 | $stdout.sync = true 10 | 11 | class Dots < Async::Container::Controller 12 | def setup(container) 13 | container.run(name: "dots", count: 1, restart: true) do |instance| 14 | instance.ready! 15 | 16 | # This is to avoid race conditions in the controller in test conditions. 17 | sleep 0.001 18 | 19 | $stdout.write "." 20 | 21 | sleep 22 | rescue Async::Container::Interrupt 23 | $stdout.write("I") 24 | rescue Async::Container::Terminate 25 | $stdout.write("T") 26 | end 27 | end 28 | end 29 | 30 | controller = Dots.new 31 | 32 | controller.run 33 | -------------------------------------------------------------------------------- /fixtures/async/container/controllers/graceful.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024-2025, by Samuel Williams. 6 | 7 | require_relative "../../../../lib/async/container/controller" 8 | 9 | $stdout.sync = true 10 | 11 | class Graceful < Async::Container::Controller 12 | def setup(container) 13 | container.run(name: "graceful", count: 1, restart: true) do |instance| 14 | instance.ready! 15 | 16 | # This is to avoid race conditions in the controller in test conditions. 17 | sleep 0.001 18 | 19 | clock = Async::Clock.start 20 | 21 | original_action = Signal.trap(:INT) do 22 | # We ignore the int, but in practical applications you would want start a graceful shutdown. 23 | $stdout.puts "Graceful shutdown...", clock.total 24 | 25 | Signal.trap(:INT, original_action) 26 | end 27 | 28 | $stdout.puts "Ready...", clock.total 29 | 30 | sleep 31 | ensure 32 | $stdout.puts "Exiting...", clock.total 33 | end 34 | end 35 | end 36 | 37 | controller = Graceful.new(graceful_stop: 0.01) 38 | 39 | begin 40 | controller.run 41 | rescue Async::Container::Terminate 42 | $stdout.puts "Terminated..." 43 | rescue Interrupt 44 | $stdout.puts "Interrupted..." 45 | end 46 | -------------------------------------------------------------------------------- /fixtures/async/container/controllers/notify.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2020-2025, by Samuel Williams. 6 | 7 | require_relative "../../../../lib/async/container" 8 | 9 | class MyController < Async::Container::Controller 10 | def setup(container) 11 | container.run(restart: false) do |instance| 12 | sleep(0.001) 13 | 14 | instance.ready! 15 | 16 | sleep(0.001) 17 | end 18 | end 19 | end 20 | 21 | controller = MyController.new 22 | controller.run 23 | -------------------------------------------------------------------------------- /fixtures/async/container/controllers/working_directory.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | require_relative "../../../../lib/async/container/controller" 8 | 9 | $stdout.sync = true 10 | 11 | class Pwd < Async::Container::Controller 12 | def setup(container) 13 | container.spawn do |instance| 14 | instance.ready! 15 | 16 | instance.exec("pwd", chdir: "/") 17 | end 18 | end 19 | end 20 | 21 | controller = Pwd.new 22 | 23 | controller.run 24 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2024, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gemspec 9 | 10 | group :maintenance, optional: true do 11 | gem "bake-gem" 12 | gem "bake-modernize" 13 | gem "bake-releases" 14 | 15 | gem "utopia-project" 16 | end 17 | 18 | group :test do 19 | gem "sus" 20 | gem "covered" 21 | gem "decode" 22 | gem "rubocop" 23 | 24 | gem "metrics" 25 | 26 | gem "bake-test" 27 | gem "bake-test-external" 28 | end 29 | -------------------------------------------------------------------------------- /gems/async-head.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | eval_gemfile "../gems.rb" 9 | 10 | gem "async", git: "https://github.com/socketry/async" 11 | -------------------------------------------------------------------------------- /gems/async-v1.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | eval_gemfile "../gems.rb" 9 | 10 | gem "async", "~> 1.0" 11 | -------------------------------------------------------------------------------- /guides/getting-started/readme.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide explains how to use `async-container` to build basic scalable systems. 4 | 5 | ## Installation 6 | 7 | Add the gem to your project: 8 | 9 | ~~~ bash 10 | $ bundle add async-container 11 | ~~~ 12 | 13 | ## Core Concepts 14 | 15 | `async-container` has several core concepts: 16 | 17 | - {ruby Async::Container::Forked} and {ruby Async::Container::Threaded} are used to manage one or more child processes and threads respectively for parallel execution. While threads share the address space which can reduce overall memory usage, processes have better isolation and fault tolerance. 18 | - {ruby Async::Container::Controller} manages one or more containers and handles graceful restarts. Containers should be implemented in such a way that multiple containers can be running at the same time. 19 | 20 | ## Containers 21 | 22 | A container represents a set of child processes (or threads) which are doing work for you. 23 | 24 | ``` ruby 25 | require 'async/container' 26 | 27 | Console.logger.debug! 28 | 29 | container = Async::Container.new 30 | 31 | container.spawn do |task| 32 | Console.debug task, "Sleeping..." 33 | sleep(1) 34 | Console.debug task, "Waking up!" 35 | end 36 | 37 | Console.debug "Waiting for container..." 38 | container.wait 39 | Console.debug "Finished." 40 | ``` 41 | 42 | ## Controllers 43 | 44 | The controller provides the life-cycle management for one or more containers of processes. It provides behaviour like starting, restarting, reloading and stopping. You can see some [example implementations in Falcon](https://github.com/socketry/falcon/blob/master/lib/falcon/controller/). If the process running the controller receives `SIGHUP` it will recreate the container gracefully. 45 | 46 | ``` ruby 47 | require 'async/container' 48 | 49 | Console.logger.debug! 50 | 51 | class Controller < Async::Container::Controller 52 | def create_container 53 | Async::Container::Forked.new 54 | # or Async::Container::Threaded.new 55 | # or Async::Container::Hybrid.new 56 | end 57 | 58 | def setup(container) 59 | container.run count: 2, restart: true do |instance| 60 | while true 61 | Console.debug(instance, "Sleeping...") 62 | sleep(1) 63 | end 64 | end 65 | end 66 | end 67 | 68 | controller = Controller.new 69 | 70 | controller.run 71 | 72 | # If you send SIGHUP to this process, it will recreate the container. 73 | ``` 74 | 75 | ## Signal Handling 76 | 77 | `SIGINT` is the reload signal. You may send this to a program to request that it reload its configuration. The default behavior is to gracefully reload the container. 78 | 79 | `SIGINT` is the interrupt signal. The terminal sends it to the foreground process when the user presses **ctrl-c**. The default behavior is to terminate the process, but it can be caught or ignored. The intention is to provide a mechanism for an orderly, graceful shutdown. 80 | 81 | `SIGQUIT` is the dump core signal. The terminal sends it to the foreground process when the user presses **ctrl-\\**. The default behavior is to terminate the process and dump core, but it can be caught or ignored. The intention is to provide a mechanism for the user to abort the process. You can look at `SIGINT` as "user-initiated happy termination" and `SIGQUIT` as "user-initiated unhappy termination." 82 | 83 | `SIGTERM` is the termination signal. The default behavior is to terminate the process, but it also can be caught or ignored. The intention is to kill the process, gracefully or not, but to first allow it a chance to cleanup. 84 | 85 | `SIGKILL` is the kill signal. The only behavior is to kill the process, immediately. As the process cannot catch the signal, it cannot cleanup, and thus this is a signal of last resort. 86 | 87 | `SIGSTOP` is the pause signal. The only behavior is to pause the process; the signal cannot be caught or ignored. The shell uses pausing (and its counterpart, resuming via `SIGCONT`) to implement job control. 88 | 89 | ## Integration 90 | 91 | ### systemd 92 | 93 | Install a template file into `/etc/systemd/system/`: 94 | 95 | ``` 96 | # my-daemon.service 97 | [Unit] 98 | Description=My Daemon 99 | AssertPathExists=/srv/ 100 | 101 | [Service] 102 | Type=notify 103 | WorkingDirectory=/srv/my-daemon 104 | ExecStart=bundle exec my-daemon 105 | Nice=5 106 | 107 | [Install] 108 | WantedBy=multi-user.target 109 | ``` 110 | -------------------------------------------------------------------------------- /lib/async/container.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2024, by Samuel Williams. 5 | 6 | require_relative "container/controller" 7 | 8 | # @namespace 9 | module Async 10 | # @namespace 11 | module Container 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/async/container/best.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require_relative "forked" 7 | require_relative "threaded" 8 | require_relative "hybrid" 9 | 10 | module Async 11 | module Container 12 | # Whether the underlying process supports fork. 13 | # @returns [Boolean] 14 | def self.fork? 15 | ::Process.respond_to?(:fork) && ::Process.respond_to?(:setpgid) 16 | end 17 | 18 | # Determins the best container class based on the underlying Ruby implementation. 19 | # Some platforms, including JRuby, don't support fork. Applications which just want a reasonable default can use this method. 20 | # @returns [Class] 21 | def self.best_container_class 22 | if fork? 23 | return Forked 24 | else 25 | return Threaded 26 | end 27 | end 28 | 29 | # Create an instance of the best container class. 30 | # @returns [Generic] Typically an instance of either {Forked} or {Threaded} containers. 31 | def self.new(*arguments, **options) 32 | best_container_class.new(*arguments, **options) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/async/container/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require "json" 7 | 8 | module Async 9 | module Container 10 | # Provides a basic multi-thread/multi-process uni-directional communication channel. 11 | class Channel 12 | # Initialize the channel using a pipe. 13 | def initialize 14 | @in, @out = ::IO.pipe 15 | end 16 | 17 | # The input end of the pipe. 18 | # @attribute [IO] 19 | attr :in 20 | 21 | # The output end of the pipe. 22 | # @attribute [IO] 23 | attr :out 24 | 25 | # Close the input end of the pipe. 26 | def close_read 27 | @in.close 28 | end 29 | 30 | # Close the output end of the pipe. 31 | def close_write 32 | @out.close 33 | end 34 | 35 | # Close both ends of the pipe. 36 | def close 37 | close_read 38 | close_write 39 | end 40 | 41 | # Receive an object from the pipe. 42 | # Internally, prefers to receive newline formatted JSON, otherwise returns a hash table with a single key `:line` which contains the line of data that could not be parsed as JSON. 43 | # @returns [Hash] 44 | def receive 45 | if data = @in.gets 46 | begin 47 | return JSON.parse(data, symbolize_names: true) 48 | rescue 49 | return {line: data} 50 | end 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/async/container/controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | require_relative "error" 7 | require_relative "best" 8 | 9 | require_relative "statistics" 10 | require_relative "notify" 11 | 12 | module Async 13 | module Container 14 | # Manages the life-cycle of one or more containers in order to support a persistent system. 15 | # e.g. a web server, job server or some other long running system. 16 | class Controller 17 | SIGHUP = Signal.list["HUP"] 18 | SIGINT = Signal.list["INT"] 19 | SIGTERM = Signal.list["TERM"] 20 | SIGUSR1 = Signal.list["USR1"] 21 | SIGUSR2 = Signal.list["USR2"] 22 | 23 | # Initialize the controller. 24 | # @parameter notify [Notify::Client] A client used for process readiness notifications. 25 | def initialize(notify: Notify.open!, container_class: Container, graceful_stop: true) 26 | @container = nil 27 | @container_class = container_class 28 | 29 | @notify = notify 30 | @signals = {} 31 | 32 | self.trap(SIGHUP) do 33 | self.restart 34 | end 35 | 36 | @graceful_stop = graceful_stop 37 | end 38 | 39 | # The state of the controller. 40 | # @returns [String] 41 | def state_string 42 | if running? 43 | "running" 44 | else 45 | "stopped" 46 | end 47 | end 48 | 49 | # A human readable representation of the controller. 50 | # @returns [String] 51 | def to_s 52 | "#{self.class} #{state_string}" 53 | end 54 | 55 | # Trap the specified signal. 56 | # @parameters signal [Symbol] The signal to trap, e.g. `:INT`. 57 | # @parameters block [Proc] The signal handler to invoke. 58 | def trap(signal, &block) 59 | @signals[signal] = block 60 | end 61 | 62 | # The current container being managed by the controller. 63 | attr :container 64 | 65 | # Create a container for the controller. 66 | # Can be overridden by a sub-class. 67 | # @returns [Generic] A specific container instance to use. 68 | def create_container 69 | @container_class.new 70 | end 71 | 72 | # Whether the controller has a running container. 73 | # @returns [Boolean] 74 | def running? 75 | !!@container 76 | end 77 | 78 | # Wait for the underlying container to start. 79 | def wait 80 | @container&.wait 81 | end 82 | 83 | # Spawn container instances into the given container. 84 | # Should be overridden by a sub-class. 85 | # @parameter container [Generic] The container, generally from {#create_container}. 86 | def setup(container) 87 | # Don't do this, otherwise calling super is risky for sub-classes: 88 | # raise NotImplementedError, "Container setup is must be implemented in derived class!" 89 | end 90 | 91 | # Start the container unless it's already running. 92 | def start 93 | unless @container 94 | Console.info(self) {"Controller starting..."} 95 | self.restart 96 | end 97 | 98 | Console.info(self) {"Controller started..."} 99 | end 100 | 101 | # Stop the container if it's running. 102 | # @parameter graceful [Boolean] Whether to give the children instances time to shut down or to kill them immediately. 103 | def stop(graceful = @graceful_stop) 104 | @container&.stop(graceful) 105 | @container = nil 106 | end 107 | 108 | # Restart the container. A new container is created, and if successful, any old container is terminated gracefully. 109 | # This is equivalent to a blue-green deployment. 110 | def restart 111 | if @container 112 | @notify&.restarting! 113 | 114 | Console.debug(self) {"Restarting container..."} 115 | else 116 | Console.debug(self) {"Starting container..."} 117 | end 118 | 119 | container = self.create_container 120 | 121 | begin 122 | self.setup(container) 123 | rescue => error 124 | @notify&.error!(error.to_s) 125 | 126 | raise SetupError, container 127 | end 128 | 129 | # Wait for all child processes to enter the ready state. 130 | Console.debug(self, "Waiting for startup...") 131 | container.wait_until_ready 132 | Console.debug(self, "Finished startup.") 133 | 134 | if container.failed? 135 | @notify&.error!("Container failed to start!") 136 | 137 | container.stop(false) 138 | 139 | raise SetupError, container 140 | end 141 | 142 | # The following swap should be atomic: 143 | old_container = @container 144 | @container = container 145 | container = nil 146 | 147 | if old_container 148 | Console.debug(self, "Stopping old container...") 149 | old_container&.stop(@graceful_stop) 150 | end 151 | 152 | @notify&.ready!(size: @container.size) 153 | ensure 154 | # If we are leaving this function with an exception, try to kill the container: 155 | container&.stop(false) 156 | end 157 | 158 | # Reload the existing container. Children instances will be reloaded using `SIGHUP`. 159 | def reload 160 | @notify&.reloading! 161 | 162 | Console.info(self) {"Reloading container: #{@container}..."} 163 | 164 | begin 165 | self.setup(@container) 166 | rescue 167 | raise SetupError, container 168 | end 169 | 170 | # Wait for all child processes to enter the ready state. 171 | Console.debug(self, "Waiting for startup...") 172 | 173 | @container.wait_until_ready 174 | 175 | Console.debug(self, "Finished startup.") 176 | 177 | if @container.failed? 178 | @notify.error!("Container failed to reload!") 179 | 180 | raise SetupError, @container 181 | else 182 | @notify&.ready! 183 | end 184 | end 185 | 186 | # Enter the controller run loop, trapping `SIGINT` and `SIGTERM`. 187 | def run 188 | @notify&.status!("Initializing controller...") 189 | 190 | with_signal_handlers do 191 | self.start 192 | 193 | while @container&.running? 194 | begin 195 | @container.wait 196 | rescue SignalException => exception 197 | if handler = @signals[exception.signo] 198 | begin 199 | handler.call 200 | rescue SetupError => error 201 | Console.error(self, error) 202 | end 203 | else 204 | raise 205 | end 206 | end 207 | end 208 | end 209 | rescue Interrupt 210 | self.stop 211 | rescue Terminate 212 | self.stop(false) 213 | ensure 214 | self.stop(false) 215 | end 216 | 217 | private def with_signal_handlers 218 | # I thought this was the default... but it doesn't always raise an exception unless you do this explicitly. 219 | 220 | interrupt_action = Signal.trap(:INT) do 221 | # We use `Thread.current.raise(...)` so that exceptions are filtered through `Thread.handle_interrupt` correctly. 222 | # $stderr.puts "Received INT signal, interrupting...", caller 223 | ::Thread.current.raise(Interrupt) 224 | end 225 | 226 | terminate_action = Signal.trap(:TERM) do 227 | # $stderr.puts "Received TERM signal, terminating...", caller 228 | ::Thread.current.raise(Terminate) 229 | end 230 | 231 | hangup_action = Signal.trap(:HUP) do 232 | # $stderr.puts "Received HUP signal, restarting...", caller 233 | ::Thread.current.raise(Restart) 234 | end 235 | 236 | ::Thread.handle_interrupt(SignalException => :never) do 237 | yield 238 | end 239 | ensure 240 | # Restore the interrupt handler: 241 | Signal.trap(:INT, interrupt_action) 242 | Signal.trap(:TERM, terminate_action) 243 | Signal.trap(:HUP, hangup_action) 244 | end 245 | end 246 | end 247 | end 248 | -------------------------------------------------------------------------------- /lib/async/container/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | module Async 7 | module Container 8 | # Represents an error that occured during container execution. 9 | class Error < StandardError 10 | end 11 | 12 | Interrupt = ::Interrupt 13 | 14 | # Similar to {Interrupt}, but represents `SIGTERM`. 15 | class Terminate < SignalException 16 | SIGTERM = Signal.list["TERM"] 17 | 18 | # Create a new terminate error. 19 | def initialize 20 | super(SIGTERM) 21 | end 22 | end 23 | 24 | # Similar to {Interrupt}, but represents `SIGHUP`. 25 | class Restart < SignalException 26 | SIGHUP = Signal.list["HUP"] 27 | 28 | # Create a new restart error. 29 | def initialize 30 | super(SIGHUP) 31 | end 32 | end 33 | 34 | # Represents the error which occured when a container failed to start up correctly. 35 | class SetupError < Error 36 | # Create a new setup error. 37 | # 38 | # @parameter container [Generic] The container that failed. 39 | def initialize(container) 40 | super("Could not create container!") 41 | 42 | @container = container 43 | end 44 | 45 | # @attribute [Generic] The container that failed. 46 | attr :container 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/async/container/forked.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2024, by Samuel Williams. 5 | 6 | require_relative "error" 7 | 8 | require_relative "generic" 9 | require_relative "channel" 10 | require_relative "notify/pipe" 11 | 12 | module Async 13 | module Container 14 | # A multi-process container which uses {Process.fork}. 15 | class Forked < Generic 16 | # Indicates that this is a multi-process container. 17 | def self.multiprocess? 18 | true 19 | end 20 | 21 | # Represents a running child process from the point of view of the parent container. 22 | class Child < Channel 23 | # Represents a running child process from the point of view of the child process. 24 | class Instance < Notify::Pipe 25 | # Wrap an instance around the {Process} instance from within the forked child. 26 | # @parameter process [Process] The process intance to wrap. 27 | def self.for(process) 28 | instance = self.new(process.out) 29 | 30 | # The child process won't be reading from the channel: 31 | process.close_read 32 | 33 | instance.name = process.name 34 | 35 | return instance 36 | end 37 | 38 | # Initialize the child process instance. 39 | # 40 | # @parameter io [IO] The IO object to use for communication. 41 | def initialize(io) 42 | super 43 | 44 | @name = nil 45 | end 46 | 47 | # Generate a hash representation of the process. 48 | # 49 | # @returns [Hash] The process as a hash, including `process_id` and `name`. 50 | def as_json(...) 51 | { 52 | process_id: ::Process.pid, 53 | name: @name, 54 | } 55 | end 56 | 57 | # Generate a JSON representation of the process. 58 | # 59 | # @returns [String] The process as JSON. 60 | def to_json(...) 61 | as_json.to_json(...) 62 | end 63 | 64 | # Set the process title to the specified value. 65 | # 66 | # @parameter value [String] The name of the process. 67 | def name= value 68 | @name = value 69 | 70 | # This sets the process title to an empty string if the name is nil: 71 | ::Process.setproctitle(@name.to_s) 72 | end 73 | 74 | # @returns [String] The name of the process. 75 | def name 76 | @name 77 | end 78 | 79 | # Replace the current child process with a different one. Forwards arguments and options to {::Process.exec}. 80 | # This method replaces the child process with the new executable, thus this method never returns. 81 | # 82 | # @parameter arguments [Array] The arguments to pass to the new process. 83 | # @parameter ready [Boolean] If true, informs the parent process that the child is ready. Otherwise, the child process will need to use a notification protocol to inform the parent process that it is ready. 84 | # @parameter options [Hash] Additional options to pass to {::Process.exec}. 85 | def exec(*arguments, ready: true, **options) 86 | if ready 87 | self.ready!(status: "(exec)") 88 | else 89 | self.before_spawn(arguments, options) 90 | end 91 | 92 | ::Process.exec(*arguments, **options) 93 | end 94 | end 95 | 96 | # Fork a child process appropriate for a container. 97 | # 98 | # @returns [Process] 99 | def self.fork(**options) 100 | # $stderr.puts fork: caller 101 | self.new(**options) do |process| 102 | ::Process.fork do 103 | # We use `Thread.current.raise(...)` so that exceptions are filtered through `Thread.handle_interrupt` correctly. 104 | Signal.trap(:INT) {::Thread.current.raise(Interrupt)} 105 | Signal.trap(:TERM) {::Thread.current.raise(Terminate)} 106 | Signal.trap(:HUP) {::Thread.current.raise(Restart)} 107 | 108 | # This could be a configuration option: 109 | ::Thread.handle_interrupt(SignalException => :immediate) do 110 | yield Instance.for(process) 111 | rescue Interrupt 112 | # Graceful exit. 113 | rescue Exception => error 114 | Console.error(self, error) 115 | 116 | exit!(1) 117 | end 118 | end 119 | end 120 | end 121 | 122 | # Spawn a child process using {::Process.spawn}. 123 | # 124 | # The child process will need to inform the parent process that it is ready using a notification protocol. 125 | # 126 | # @parameter arguments [Array] The arguments to pass to the new process. 127 | # @parameter name [String] The name of the process. 128 | # @parameter options [Hash] Additional options to pass to {::Process.spawn}. 129 | def self.spawn(*arguments, name: nil, **options) 130 | self.new(name: name) do |process| 131 | Notify::Pipe.new(process.out).before_spawn(arguments, options) 132 | 133 | ::Process.spawn(*arguments, **options) 134 | end 135 | end 136 | 137 | # Initialize the process. 138 | # @parameter name [String] The name to use for the child process. 139 | def initialize(name: nil) 140 | super() 141 | 142 | @name = name 143 | @status = nil 144 | @pid = nil 145 | 146 | @pid = yield(self) 147 | 148 | # The parent process won't be writing to the channel: 149 | self.close_write 150 | end 151 | 152 | # Convert the child process to a hash, suitable for serialization. 153 | # 154 | # @returns [Hash] The request as a hash. 155 | def as_json(...) 156 | { 157 | name: @name, 158 | pid: @pid, 159 | status: @status&.to_i, 160 | } 161 | end 162 | 163 | # Convert the request to JSON. 164 | # 165 | # @returns [String] The request as JSON. 166 | def to_json(...) 167 | as_json.to_json(...) 168 | end 169 | 170 | # Set the name of the process. 171 | # Invokes {::Process.setproctitle} if invoked in the child process. 172 | def name= value 173 | @name = value 174 | 175 | # If we are the child process: 176 | ::Process.setproctitle(@name) if @pid.nil? 177 | end 178 | 179 | # The name of the process. 180 | # @attribute [String] 181 | attr :name 182 | 183 | # @attribute [Integer] The process identifier. 184 | attr :pid 185 | 186 | # A human readable representation of the process. 187 | # @returns [String] 188 | def inspect 189 | "\#<#{self.class} name=#{@name.inspect} status=#{@status.inspect} pid=#{@pid.inspect}>" 190 | end 191 | 192 | alias to_s inspect 193 | 194 | # Invoke {#terminate!} and then {#wait} for the child process to exit. 195 | def close 196 | self.terminate! 197 | self.wait 198 | ensure 199 | super 200 | end 201 | 202 | # Send `SIGINT` to the child process. 203 | def interrupt! 204 | unless @status 205 | ::Process.kill(:INT, @pid) 206 | end 207 | end 208 | 209 | # Send `SIGTERM` to the child process. 210 | def terminate! 211 | unless @status 212 | ::Process.kill(:TERM, @pid) 213 | end 214 | end 215 | 216 | # Send `SIGKILL` to the child process. 217 | def kill! 218 | unless @status 219 | ::Process.kill(:KILL, @pid) 220 | end 221 | end 222 | 223 | # Send `SIGHUP` to the child process. 224 | def restart! 225 | unless @status 226 | ::Process.kill(:HUP, @pid) 227 | end 228 | end 229 | 230 | # Wait for the child process to exit. 231 | # @asynchronous This method may block. 232 | # 233 | # @returns [::Process::Status] The process exit status. 234 | def wait 235 | if @pid && @status.nil? 236 | Console.debug(self, "Waiting for process to exit...", pid: @pid) 237 | 238 | _, @status = ::Process.wait2(@pid, ::Process::WNOHANG) 239 | 240 | while @status.nil? 241 | sleep(0.1) 242 | 243 | _, @status = ::Process.wait2(@pid, ::Process::WNOHANG) 244 | 245 | if @status.nil? 246 | Console.warn(self) {"Process #{@pid} is blocking, has it exited?"} 247 | end 248 | end 249 | end 250 | 251 | Console.debug(self, "Process exited.", pid: @pid, status: @status) 252 | 253 | return @status 254 | end 255 | end 256 | 257 | 258 | # Start a named child process and execute the provided block in it. 259 | # @parameter name [String] The name (title) of the child process. 260 | # @parameter block [Proc] The block to execute in the child process. 261 | def start(name, &block) 262 | Child.fork(name: name, &block) 263 | end 264 | end 265 | end 266 | end 267 | -------------------------------------------------------------------------------- /lib/async/container/generic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require "etc" 7 | require "async/clock" 8 | 9 | require_relative "group" 10 | require_relative "keyed" 11 | require_relative "statistics" 12 | 13 | module Async 14 | module Container 15 | # An environment variable key to override {.processor_count}. 16 | ASYNC_CONTAINER_PROCESSOR_COUNT = "ASYNC_CONTAINER_PROCESSOR_COUNT" 17 | 18 | # The processor count which may be used for the default number of container threads/processes. You can override the value provided by the system by specifying the `ASYNC_CONTAINER_PROCESSOR_COUNT` environment variable. 19 | # @returns [Integer] The number of hardware processors which can run threads/processes simultaneously. 20 | # @raises [RuntimeError] If the process count is invalid. 21 | def self.processor_count(env = ENV) 22 | count = env.fetch(ASYNC_CONTAINER_PROCESSOR_COUNT) do 23 | Etc.nprocessors rescue 1 24 | end.to_i 25 | 26 | if count < 1 27 | raise RuntimeError, "Invalid processor count #{count}!" 28 | end 29 | 30 | return count 31 | end 32 | 33 | # A base class for implementing containers. 34 | class Generic 35 | # Run a new container. 36 | def self.run(...) 37 | self.new.run(...) 38 | end 39 | 40 | UNNAMED = "Unnamed" 41 | 42 | # Initialize the container. 43 | # 44 | # @parameter options [Hash] Options passed to the {Group} instance. 45 | def initialize(**options) 46 | @group = Group.new(**options) 47 | @running = true 48 | 49 | @state = {} 50 | 51 | @statistics = Statistics.new 52 | @keyed = {} 53 | end 54 | 55 | # @attribute [Group] The group of running children instances. 56 | attr :group 57 | 58 | # @returns [Integer] The number of running children instances. 59 | def size 60 | @group.size 61 | end 62 | 63 | # @attribute [Hash(Child, Hash)] The state of each child instance. 64 | attr :state 65 | 66 | # A human readable representation of the container. 67 | # @returns [String] 68 | def to_s 69 | "#{self.class} with #{@statistics.spawns} spawns and #{@statistics.failures} failures." 70 | end 71 | 72 | # Look up a child process by key. 73 | # A key could be a symbol, a file path, or something else which the child instance represents. 74 | def [] key 75 | @keyed[key]&.value 76 | end 77 | 78 | # Statistics relating to the behavior of children instances. 79 | # @attribute [Statistics] 80 | attr :statistics 81 | 82 | # Whether any failures have occurred within the container. 83 | # @returns [Boolean] 84 | def failed? 85 | @statistics.failed? 86 | end 87 | 88 | # Whether the container has running children instances. 89 | def running? 90 | @group.running? 91 | end 92 | 93 | # Sleep until some state change occurs. 94 | # @parameter duration [Numeric] the maximum amount of time to sleep for. 95 | def sleep(duration = nil) 96 | @group.sleep(duration) 97 | end 98 | 99 | # Wait until all spawned tasks are completed. 100 | def wait 101 | @group.wait 102 | end 103 | 104 | # Returns true if all children instances have the specified status flag set. 105 | # e.g. `:ready`. 106 | # This state is updated by the process readiness protocol mechanism. See {Notify::Client} for more details. 107 | # @returns [Boolean] 108 | def status?(flag) 109 | # This also returns true if all processes have exited/failed: 110 | @state.all?{|_, state| state[flag]} 111 | end 112 | 113 | # Wait until all the children instances have indicated that they are ready. 114 | # @returns [Boolean] The children all became ready. 115 | def wait_until_ready 116 | while true 117 | Console.debug(self) do |buffer| 118 | buffer.puts "Waiting for ready:" 119 | @state.each do |child, state| 120 | buffer.puts "\t#{child.inspect}: #{state}" 121 | end 122 | end 123 | 124 | self.sleep 125 | 126 | if self.status?(:ready) 127 | Console.logger.debug(self) do |buffer| 128 | buffer.puts "All ready:" 129 | @state.each do |child, state| 130 | buffer.puts "\t#{child.inspect}: #{state}" 131 | end 132 | end 133 | 134 | return true 135 | end 136 | end 137 | end 138 | 139 | # Stop the children instances. 140 | # @parameter timeout [Boolean | Numeric] Whether to stop gracefully, or a specific timeout. 141 | def stop(timeout = true) 142 | @running = false 143 | @group.stop(timeout) 144 | 145 | if @group.running? 146 | Console.warn(self) {"Group is still running after stopping it!"} 147 | end 148 | ensure 149 | @running = true 150 | end 151 | 152 | protected def health_check_failed!(child, age_clock, health_check_timeout) 153 | Console.warn(self, "Child failed health check!", child: child, age: age_clock.total, health_check_timeout: health_check_timeout) 154 | 155 | # If the child has failed the health check, we assume the worst and kill it immediately: 156 | child.kill! 157 | end 158 | 159 | # Spawn a child instance into the container. 160 | # @parameter name [String] The name of the child instance. 161 | # @parameter restart [Boolean] Whether to restart the child instance if it fails. 162 | # @parameter key [Symbol] A key used for reloading child instances. 163 | # @parameter health_check_timeout [Numeric | Nil] The maximum time a child instance can run without updating its state, before it is terminated as unhealthy. 164 | def spawn(name: nil, restart: false, key: nil, health_check_timeout: nil, &block) 165 | name ||= UNNAMED 166 | 167 | if mark?(key) 168 | Console.debug(self) {"Reusing existing child for #{key}: #{name}"} 169 | return false 170 | end 171 | 172 | @statistics.spawn! 173 | 174 | fiber do 175 | while @running 176 | child = self.start(name, &block) 177 | 178 | state = insert(key, child) 179 | 180 | # If a health check is specified, we will monitor the child process and terminate it if it does not update its state within the specified time. 181 | if health_check_timeout 182 | age_clock = state[:age] = Clock.start 183 | end 184 | 185 | begin 186 | status = @group.wait_for(child) do |message| 187 | case message 188 | when :health_check! 189 | if health_check_timeout&.<(age_clock.total) 190 | health_check_failed!(child, age_clock, health_check_timeout) 191 | end 192 | else 193 | state.update(message) 194 | age_clock&.reset! 195 | end 196 | end 197 | ensure 198 | delete(key, child) 199 | end 200 | 201 | if status.success? 202 | Console.debug(self) {"#{child} exited with #{status}"} 203 | else 204 | @statistics.failure! 205 | Console.error(self, status: status) 206 | end 207 | 208 | if restart 209 | @statistics.restart! 210 | else 211 | break 212 | end 213 | end 214 | end.resume 215 | 216 | return true 217 | end 218 | 219 | # Run multiple instances of the same block in the container. 220 | # @parameter count [Integer] The number of instances to start. 221 | def run(count: Container.processor_count, **options, &block) 222 | count.times do 223 | spawn(**options, &block) 224 | end 225 | 226 | return self 227 | end 228 | 229 | # @deprecated Please use {spawn} or {run} instead. 230 | def async(**options, &block) 231 | # warn "#{self.class}##{__method__} is deprecated, please use `spawn` or `run` instead.", uplevel: 1 232 | 233 | require "async" 234 | 235 | spawn(**options) do |instance| 236 | Async(instance, &block) 237 | end 238 | end 239 | 240 | # Reload the container's keyed instances. 241 | def reload 242 | @keyed.each_value(&:clear!) 243 | 244 | yield 245 | 246 | dirty = false 247 | 248 | @keyed.delete_if do |key, value| 249 | value.stop? && (dirty = true) 250 | end 251 | 252 | return dirty 253 | end 254 | 255 | # Mark the container's keyed instance which ensures that it won't be discarded. 256 | def mark?(key) 257 | if key 258 | if value = @keyed[key] 259 | value.mark! 260 | 261 | return true 262 | end 263 | end 264 | 265 | return false 266 | end 267 | 268 | # Whether a child instance exists for the given key. 269 | def key?(key) 270 | if key 271 | @keyed.key?(key) 272 | end 273 | end 274 | 275 | protected 276 | 277 | # Register the child (value) as running. 278 | def insert(key, child) 279 | if key 280 | @keyed[key] = Keyed.new(key, child) 281 | end 282 | 283 | state = {} 284 | 285 | @state[child] = state 286 | 287 | return state 288 | end 289 | 290 | # Clear the child (value) as running. 291 | def delete(key, child) 292 | if key 293 | @keyed.delete(key) 294 | end 295 | 296 | @state.delete(child) 297 | end 298 | 299 | private 300 | 301 | if Fiber.respond_to?(:blocking?) 302 | def fiber(&block) 303 | Fiber.new(blocking: true, &block) 304 | end 305 | else 306 | def fiber(&block) 307 | Fiber.new(&block) 308 | end 309 | end 310 | end 311 | end 312 | end 313 | -------------------------------------------------------------------------------- /lib/async/container/group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "fiber" 7 | require "async/clock" 8 | 9 | require_relative "error" 10 | 11 | module Async 12 | module Container 13 | # Manages a group of running processes. 14 | class Group 15 | # Initialize an empty group. 16 | # 17 | # @parameter health_check_interval [Numeric | Nil] The (biggest) interval at which health checks are performed. 18 | def initialize(health_check_interval: 1.0) 19 | @health_check_interval = health_check_interval 20 | 21 | # The running fibers, indexed by IO: 22 | @running = {} 23 | 24 | # This queue allows us to wait for processes to complete, without spawning new processes as a result. 25 | @queue = nil 26 | end 27 | 28 | # @returns [String] A human-readable representation of the group. 29 | def inspect 30 | "#<#{self.class} running=#{@running.size}>" 31 | end 32 | 33 | # @attribute [Hash(IO, Fiber)] the running tasks, indexed by IO. 34 | attr :running 35 | 36 | # @returns [Integer] The number of running processes. 37 | def size 38 | @running.size 39 | end 40 | 41 | # Whether the group contains any running processes. 42 | # @returns [Boolean] 43 | def running? 44 | @running.any? 45 | end 46 | 47 | # Whether the group contains any running processes. 48 | # @returns [Boolean] 49 | def any? 50 | @running.any? 51 | end 52 | 53 | # Whether the group is empty. 54 | # @returns [Boolean] 55 | def empty? 56 | @running.empty? 57 | end 58 | 59 | # Sleep for at most the specified duration until some state change occurs. 60 | def sleep(duration) 61 | self.resume 62 | self.suspend 63 | 64 | self.wait_for_children(duration) 65 | end 66 | 67 | # Begin any outstanding queued processes and wait for them indefinitely. 68 | def wait 69 | self.resume 70 | 71 | with_health_checks do |duration| 72 | self.wait_for_children(duration) 73 | end 74 | end 75 | 76 | private def with_health_checks 77 | if @health_check_interval 78 | health_check_clock = Clock.start 79 | 80 | while self.running? 81 | duration = [@health_check_interval - health_check_clock.total, 0].max 82 | 83 | yield duration 84 | 85 | if health_check_clock.total > @health_check_interval 86 | self.health_check! 87 | health_check_clock.reset! 88 | end 89 | end 90 | else 91 | while self.running? 92 | yield nil 93 | end 94 | end 95 | end 96 | 97 | # Perform a health check on all running processes. 98 | def health_check! 99 | @running.each_value do |fiber| 100 | fiber.resume(:health_check!) 101 | end 102 | end 103 | 104 | # Interrupt all running processes. 105 | # This resumes the controlling fiber with an instance of {Interrupt}. 106 | def interrupt 107 | Console.info(self, "Sending interrupt to #{@running.size} running processes...") 108 | @running.each_value do |fiber| 109 | fiber.resume(Interrupt) 110 | end 111 | end 112 | 113 | # Terminate all running processes. 114 | # This resumes the controlling fiber with an instance of {Terminate}. 115 | def terminate 116 | Console.info(self, "Sending terminate to #{@running.size} running processes...") 117 | @running.each_value do |fiber| 118 | fiber.resume(Terminate) 119 | end 120 | end 121 | 122 | # Stop all child processes using {#terminate}. 123 | # @parameter timeout [Boolean | Numeric | Nil] If specified, invoke a graceful shutdown using {#interrupt} first. 124 | def stop(timeout = 1) 125 | Console.debug(self, "Stopping all processes...", timeout: timeout) 126 | # Use a default timeout if not specified: 127 | timeout = 1 if timeout == true 128 | 129 | if timeout 130 | start_time = Async::Clock.now 131 | 132 | self.interrupt 133 | 134 | while self.any? 135 | duration = Async::Clock.now - start_time 136 | remaining = timeout - duration 137 | 138 | if remaining >= 0 139 | self.wait_for_children(duration) 140 | else 141 | self.wait_for_children(0) 142 | break 143 | end 144 | end 145 | end 146 | 147 | # Terminate all children: 148 | self.terminate if any? 149 | 150 | # Wait for all children to exit: 151 | self.wait 152 | end 153 | 154 | # Wait for a message in the specified {Channel}. 155 | def wait_for(channel) 156 | io = channel.in 157 | 158 | @running[io] = Fiber.current 159 | 160 | while @running.key?(io) 161 | # Wait for some event on the channel: 162 | result = Fiber.yield 163 | 164 | if result == Interrupt 165 | channel.interrupt! 166 | elsif result == Terminate 167 | channel.terminate! 168 | elsif result 169 | yield result 170 | elsif message = channel.receive 171 | yield message 172 | else 173 | # Wait for the channel to exit: 174 | return channel.wait 175 | end 176 | end 177 | ensure 178 | @running.delete(io) 179 | end 180 | 181 | protected 182 | 183 | def wait_for_children(duration = nil) 184 | # This log is a big noisy and doesn't really provide a lot of useful information. 185 | # Console.debug(self, "Waiting for children...", duration: duration, running: @running) 186 | 187 | if !@running.empty? 188 | # Maybe consider using a proper event loop here: 189 | if ready = self.select(duration) 190 | ready.each do |io| 191 | @running[io].resume 192 | end 193 | end 194 | end 195 | end 196 | 197 | # Wait for a child process to exit OR a signal to be received. 198 | def select(duration) 199 | ::Thread.handle_interrupt(SignalException => :immediate) do 200 | readable, _, _ = ::IO.select(@running.keys, nil, nil, duration) 201 | 202 | return readable 203 | end 204 | end 205 | 206 | def yield 207 | if @queue 208 | fiber = Fiber.current 209 | 210 | @queue << fiber 211 | Fiber.yield 212 | end 213 | end 214 | 215 | def suspend 216 | @queue ||= [] 217 | end 218 | 219 | def resume 220 | if @queue 221 | queue = @queue 222 | @queue = nil 223 | 224 | queue.each(&:resume) 225 | end 226 | end 227 | end 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /lib/async/container/hybrid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | # Copyright, 2022, by Anton Sozontov. 6 | 7 | require_relative "forked" 8 | require_relative "threaded" 9 | 10 | module Async 11 | module Container 12 | # Provides a hybrid multi-process multi-thread container. 13 | class Hybrid < Forked 14 | # Run multiple instances of the same block in the container. 15 | # @parameter count [Integer] The number of instances to start. 16 | # @parameter forks [Integer] The number of processes to fork. 17 | # @parameter threads [Integer] the number of threads to start. 18 | # @parameter health_check_timeout [Numeric] The timeout for health checks, in seconds. Passed into the child {Threaded} containers. 19 | def run(count: nil, forks: nil, threads: nil, health_check_timeout: nil, **options, &block) 20 | processor_count = Container.processor_count 21 | count ||= processor_count ** 2 22 | forks ||= [processor_count, count].min 23 | threads ||= (count / forks).ceil 24 | 25 | forks.times do 26 | self.spawn(**options) do |instance| 27 | container = Threaded.new 28 | 29 | container.run(count: threads, health_check_timeout: health_check_timeout, **options, &block) 30 | 31 | container.wait_until_ready 32 | instance.ready! 33 | 34 | container.wait 35 | rescue Async::Container::Terminate 36 | # Stop it immediately: 37 | container.stop(false) 38 | raise 39 | ensure 40 | # Stop it gracefully (also code path for Interrupt): 41 | container.stop 42 | end 43 | end 44 | 45 | return self 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/async/container/keyed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2022, by Samuel Williams. 5 | 6 | module Async 7 | module Container 8 | # Tracks a key/value pair such that unmarked keys can be identified and cleaned up. 9 | # This helps implement persistent processes that start up child processes per directory or configuration file. If those directories and/or configuration files are removed, the child process can then be cleaned up automatically, because those key/value pairs will not be marked when reloading the container. 10 | class Keyed 11 | # Initialize the keyed instance 12 | # 13 | # @parameter key [Object] The key. 14 | # @parameter value [Object] The value. 15 | def initialize(key, value) 16 | @key = key 17 | @value = value 18 | @marked = true 19 | end 20 | 21 | # @attribute [Object] The key value, normally a symbol or a file-system path. 22 | attr :key 23 | 24 | # @attribute [Object] The value, normally a child instance. 25 | attr :value 26 | 27 | # @returns [Boolean] True if the instance has been marked, during reloading the container. 28 | def marked? 29 | @marked 30 | end 31 | 32 | # Mark the instance. This will indiciate that the value is still in use/active. 33 | def mark! 34 | @marked = true 35 | end 36 | 37 | # Clear the instance. This is normally done before reloading a container. 38 | def clear! 39 | @marked = false 40 | end 41 | 42 | # Stop the instance if it was not marked. 43 | # 44 | # @returns [Boolean] True if the instance was stopped. 45 | def stop? 46 | unless @marked 47 | @value.stop 48 | return true 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/async/container/notify.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative "notify/pipe" 7 | require_relative "notify/socket" 8 | require_relative "notify/console" 9 | require_relative "notify/log" 10 | 11 | module Async 12 | module Container 13 | module Notify 14 | @client = nil 15 | 16 | # Select the best available notification client. 17 | # We cache the client on a per-process basis. Because that's the relevant scope for process readiness protocols. 18 | def self.open! 19 | @client ||= ( 20 | Pipe.open! || 21 | Socket.open! || 22 | Log.open! || 23 | Console.open! 24 | ) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/async/container/notify/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2022, by Samuel Williams. 5 | 6 | module Async 7 | module Container 8 | # Handles the details of several process readiness protocols. 9 | module Notify 10 | # Represents a client that can send messages to the parent controller in order to notify it of readiness, status changes, etc. 11 | # 12 | # A process readiness protocol (e.g. `sd_notify`) is a simple protocol for a child process to notify the parent process that it is ready (e.g. to accept connections, to process requests, etc). This can help dependency-based startup systems to start services in the correct order, and to handle failures gracefully. 13 | class Client 14 | # Notify the parent controller that the child has become ready, with a brief status message. 15 | # @parameters message [Hash] Additional details to send with the message. 16 | def ready!(**message) 17 | send(ready: true, **message) 18 | end 19 | 20 | # Notify the parent controller that the child is reloading. 21 | # @parameters message [Hash] Additional details to send with the message. 22 | def reloading!(**message) 23 | message[:ready] = false 24 | message[:reloading] = true 25 | message[:status] ||= "Reloading..." 26 | 27 | send(**message) 28 | end 29 | 30 | # Notify the parent controller that the child is restarting. 31 | # @parameters message [Hash] Additional details to send with the message. 32 | def restarting!(**message) 33 | message[:ready] = false 34 | message[:reloading] = true 35 | message[:status] ||= "Restarting..." 36 | 37 | send(**message) 38 | end 39 | 40 | # Notify the parent controller that the child is stopping. 41 | # @parameters message [Hash] Additional details to send with the message. 42 | def stopping!(**message) 43 | message[:stopping] = true 44 | 45 | send(**message) 46 | end 47 | 48 | # Notify the parent controller of a status change. 49 | # @parameters text [String] The details of the status change. 50 | def status!(text) 51 | send(status: text) 52 | end 53 | 54 | # Notify the parent controller of an error condition. 55 | # @parameters text [String] The details of the error condition. 56 | # @parameters message [Hash] Additional details to send with the message. 57 | def error!(text, **message) 58 | send(status: text, **message) 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/async/container/notify/console.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative "client" 7 | 8 | require "console" 9 | 10 | module Async 11 | module Container 12 | module Notify 13 | # Implements a general process readiness protocol with output to the local console. 14 | class Console < Client 15 | # Open a notification client attached to the current console. 16 | def self.open!(logger = ::Console) 17 | self.new(logger) 18 | end 19 | 20 | # Initialize the notification client. 21 | # @parameter logger [Console::Logger] The console logger instance to send messages to. 22 | def initialize(logger) 23 | @logger = logger 24 | end 25 | 26 | # Send a message to the console. 27 | def send(level: :info, **message) 28 | @logger.public_send(level, self) {message} 29 | end 30 | 31 | # Send an error message to the console. 32 | # @parameters text [String] The details of the error condition. 33 | # @parameters message [Hash] Additional details to send with the message. 34 | def error!(text, **message) 35 | send(status: text, level: :error, **message) 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/async/container/notify/log.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative "client" 7 | require "socket" 8 | 9 | module Async 10 | module Container 11 | module Notify 12 | # Represents a client that uses a local log file to communicate readiness, status changes, etc. 13 | class Log < Client 14 | # The name of the environment variable which contains the path to the notification socket. 15 | NOTIFY_LOG = "NOTIFY_LOG" 16 | 17 | # Open a notification client attached to the current {NOTIFY_LOG} if possible. 18 | def self.open!(environment = ENV) 19 | if path = environment.delete(NOTIFY_LOG) 20 | self.new(path) 21 | end 22 | end 23 | 24 | # Initialize the notification client. 25 | # @parameter path [String] The path to the UNIX socket used for sending messages to the process manager. 26 | def initialize(path) 27 | @path = path 28 | end 29 | 30 | # @attribute [String] The path to the UNIX socket used for sending messages to the controller. 31 | attr :path 32 | 33 | # Send the given message. 34 | # @parameter message [Hash] 35 | def send(**message) 36 | data = JSON.dump(message) 37 | 38 | File.open(@path, "a") do |file| 39 | file.puts(data) 40 | end 41 | end 42 | 43 | # Send the specified error. 44 | # `sd_notify` requires an `errno` key, which defaults to `-1` to indicate a generic error. 45 | def error!(text, **message) 46 | message[:errno] ||= -1 47 | 48 | super 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/async/container/notify/pipe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | # Copyright, 2020, by Juan Antonio Martín Lucas. 6 | 7 | require_relative "client" 8 | 9 | require "json" 10 | 11 | module Async 12 | module Container 13 | module Notify 14 | # Implements a process readiness protocol using an inherited pipe file descriptor. 15 | class Pipe < Client 16 | # The environment variable key which contains the pipe file descriptor. 17 | NOTIFY_PIPE = "NOTIFY_PIPE" 18 | 19 | # Open a notification client attached to the current {NOTIFY_PIPE} if possible. 20 | def self.open!(environment = ENV) 21 | if descriptor = environment.delete(NOTIFY_PIPE) 22 | self.new(::IO.for_fd(descriptor.to_i)) 23 | end 24 | rescue Errno::EBADF => error 25 | Console.error(self) {error} 26 | 27 | return nil 28 | end 29 | 30 | # Initialize the notification client. 31 | # @parameter io [IO] An IO instance used for sending messages. 32 | def initialize(io) 33 | @io = io 34 | end 35 | 36 | # Inserts or duplicates the environment given an argument array. 37 | # Sets or clears it in a way that is suitable for {::Process.spawn}. 38 | def before_spawn(arguments, options) 39 | environment = environment_for(arguments) 40 | 41 | # Use `notify_pipe` option if specified: 42 | if notify_pipe = options.delete(:notify_pipe) 43 | options[notify_pipe] = @io 44 | environment[NOTIFY_PIPE] = notify_pipe.to_s 45 | 46 | # Use stdout if it's not redirected: 47 | # This can cause issues if the user expects stdout to be connected to a terminal. 48 | # elsif !options.key?(:out) 49 | # options[:out] = @io 50 | # environment[NOTIFY_PIPE] = "1" 51 | 52 | # Use fileno 3 if it's available: 53 | elsif !options.key?(3) 54 | options[3] = @io 55 | environment[NOTIFY_PIPE] = "3" 56 | 57 | # Otherwise, give up! 58 | else 59 | raise ArgumentError, "Please specify valid file descriptor for notify_pipe!" 60 | end 61 | end 62 | 63 | # Formats the message using JSON and sends it to the parent controller. 64 | # This is suitable for use with {Channel}. 65 | def send(**message) 66 | data = ::JSON.dump(message) 67 | 68 | @io.puts(data) 69 | @io.flush 70 | end 71 | 72 | private 73 | 74 | def environment_for(arguments) 75 | # Insert or duplicate the environment hash which is the first argument: 76 | if arguments.first.is_a?(Hash) 77 | environment = arguments[0] = arguments.first.dup 78 | else 79 | arguments.unshift(environment = Hash.new) 80 | end 81 | 82 | return environment 83 | end 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/async/container/notify/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | # Copyright, 2020, by Olle Jonsson. 6 | 7 | require "tmpdir" 8 | require "socket" 9 | require "securerandom" 10 | 11 | module Async 12 | module Container 13 | module Notify 14 | # A simple UDP server that can be used to receive messages from a child process, tracking readiness, status changes, etc. 15 | class Server 16 | MAXIMUM_MESSAGE_SIZE = 4096 17 | 18 | # Parse a message, according to the `sd_notify` protocol. 19 | # 20 | # @parameter message [String] The message to parse. 21 | # @returns [Hash] The parsed message. 22 | def self.load(message) 23 | lines = message.split("\n") 24 | 25 | lines.pop if lines.last == "" 26 | 27 | pairs = lines.map do |line| 28 | key, value = line.split("=", 2) 29 | 30 | key = key.downcase.to_sym 31 | 32 | if value == "0" 33 | value = false 34 | elsif value == "1" 35 | value = true 36 | elsif key == :errno and value =~ /\A\-?\d+\z/ 37 | value = Integer(value) 38 | end 39 | 40 | next [key, value] 41 | end 42 | 43 | return Hash[pairs] 44 | end 45 | 46 | # Generate a new unique path for the UNIX socket. 47 | # 48 | # @returns [String] The path for the UNIX socket. 49 | def self.generate_path 50 | File.expand_path( 51 | "async-container-#{::Process.pid}-#{SecureRandom.hex(8)}.ipc", 52 | Dir.tmpdir 53 | ) 54 | end 55 | 56 | # Open a new server instance with a temporary and unique path. 57 | def self.open(path = self.generate_path) 58 | self.new(path) 59 | end 60 | 61 | # Initialize the server with the given path. 62 | # 63 | # @parameter path [String] The path to the UNIX socket. 64 | def initialize(path) 65 | @path = path 66 | end 67 | 68 | # @attribute [String] The path to the UNIX socket. 69 | attr :path 70 | 71 | # Generate a bound context for receiving messages. 72 | # 73 | # @returns [Context] The bound context. 74 | def bind 75 | Context.new(@path) 76 | end 77 | 78 | # A bound context for receiving messages. 79 | class Context 80 | # Initialize the context with the given path. 81 | # 82 | # @parameter path [String] The path to the UNIX socket. 83 | def initialize(path) 84 | @path = path 85 | @bound = Addrinfo.unix(@path, ::Socket::SOCK_DGRAM).bind 86 | 87 | @state = {} 88 | end 89 | 90 | # Close the bound context. 91 | def close 92 | @bound.close 93 | 94 | File.unlink(@path) 95 | end 96 | 97 | # Receive a message from the bound context. 98 | # 99 | # @returns [Hash] The parsed message. 100 | def receive 101 | while true 102 | data, _address, _flags, *_controls = @bound.recvmsg(MAXIMUM_MESSAGE_SIZE) 103 | 104 | message = Server.load(data) 105 | 106 | if block_given? 107 | yield message 108 | else 109 | return message 110 | end 111 | end 112 | end 113 | end 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/async/container/notify/socket.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative "client" 7 | require "socket" 8 | 9 | module Async 10 | module Container 11 | module Notify 12 | # Implements the systemd NOTIFY_SOCKET process readiness protocol. 13 | # See for more details of the underlying protocol. 14 | class Socket < Client 15 | # The name of the environment variable which contains the path to the notification socket. 16 | NOTIFY_SOCKET = "NOTIFY_SOCKET" 17 | 18 | # The maximum allowed size of the UDP message. 19 | MAXIMUM_MESSAGE_SIZE = 4096 20 | 21 | # Open a notification client attached to the current {NOTIFY_SOCKET} if possible. 22 | def self.open!(environment = ENV) 23 | if path = environment.delete(NOTIFY_SOCKET) 24 | self.new(path) 25 | end 26 | end 27 | 28 | # Initialize the notification client. 29 | # @parameter path [String] The path to the UNIX socket used for sending messages to the process manager. 30 | def initialize(path) 31 | @path = path 32 | @address = Addrinfo.unix(path, ::Socket::SOCK_DGRAM) 33 | end 34 | 35 | # @attribute [String] The path to the UNIX socket used for sending messages to the controller. 36 | attr :path 37 | 38 | # Dump a message in the format requied by `sd_notify`. 39 | # @parameter message [Hash] Keys and values should be string convertible objects. Values which are `true`/`false` are converted to `1`/`0` respectively. 40 | def dump(message) 41 | buffer = String.new 42 | 43 | message.each do |key, value| 44 | # Conversions required by NOTIFY_SOCKET specifications: 45 | if value == true 46 | value = 1 47 | elsif value == false 48 | value = 0 49 | end 50 | 51 | buffer << "#{key.to_s.upcase}=#{value}\n" 52 | end 53 | 54 | return buffer 55 | end 56 | 57 | # Send the given message. 58 | # @parameter message [Hash] 59 | def send(**message) 60 | data = dump(message) 61 | 62 | if data.bytesize > MAXIMUM_MESSAGE_SIZE 63 | raise ArgumentError, "Message length #{data.bytesize} exceeds #{MAXIMUM_MESSAGE_SIZE}: #{message.inspect}" 64 | end 65 | 66 | @address.connect do |peer| 67 | peer.sendmsg(data) 68 | end 69 | end 70 | 71 | # Send the specified error. 72 | # `sd_notify` requires an `errno` key, which defaults to `-1` to indicate a generic error. 73 | def error!(text, **message) 74 | message[:errno] ||= -1 75 | 76 | super 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/async/container/statistics.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require "async/reactor" 7 | 8 | module Async 9 | module Container 10 | # Tracks various statistics relating to child instances in a container. 11 | class Statistics 12 | # Initialize the statistics all to 0. 13 | def initialize 14 | @spawns = 0 15 | @restarts = 0 16 | @failures = 0 17 | end 18 | 19 | # How many child instances have been spawned. 20 | # @attribute [Integer] 21 | attr :spawns 22 | 23 | # How many child instances have been restarted. 24 | # @attribute [Integer] 25 | attr :restarts 26 | 27 | # How many child instances have failed. 28 | # @attribute [Integer] 29 | attr :failures 30 | 31 | # Increment the number of spawns by 1. 32 | def spawn! 33 | @spawns += 1 34 | end 35 | 36 | # Increment the number of restarts by 1. 37 | def restart! 38 | @restarts += 1 39 | end 40 | 41 | # Increment the number of failures by 1. 42 | def failure! 43 | @failures += 1 44 | end 45 | 46 | # Whether there have been any failures. 47 | # @returns [Boolean] If the failure count is greater than 0. 48 | def failed? 49 | @failures > 0 50 | end 51 | 52 | # Append another statistics instance into this one. 53 | # @parameter other [Statistics] The statistics to append. 54 | def << other 55 | @spawns += other.spawns 56 | @restarts += other.restarts 57 | @failures += other.failures 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/async/container/threaded.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2025, by Samuel Williams. 5 | 6 | require_relative "generic" 7 | require_relative "channel" 8 | require_relative "notify/pipe" 9 | 10 | module Async 11 | module Container 12 | # A multi-thread container which uses {Thread.fork}. 13 | class Threaded < Generic 14 | # Indicates that this is not a multi-process container. 15 | def self.multiprocess? 16 | false 17 | end 18 | 19 | # Represents a running child thread from the point of view of the parent container. 20 | class Child < Channel 21 | # Used to propagate the exit status of a child process invoked by {Instance#exec}. 22 | class Exit < Exception 23 | # Initialize the exit status. 24 | # @parameter status [::Process::Status] The process exit status. 25 | def initialize(status) 26 | @status = status 27 | end 28 | 29 | # The process exit status. 30 | # @attribute [::Process::Status] 31 | attr :status 32 | 33 | # The process exit status if it was an error. 34 | # @returns [::Process::Status | Nil] 35 | def error 36 | unless status.success? 37 | status 38 | end 39 | end 40 | end 41 | 42 | # Represents a running child thread from the point of view of the child thread. 43 | class Instance < Notify::Pipe 44 | # Wrap an instance around the {Thread} instance from within the threaded child. 45 | # @parameter thread [Thread] The thread intance to wrap. 46 | def self.for(thread) 47 | instance = self.new(thread.out) 48 | 49 | return instance 50 | end 51 | 52 | # Initialize the child thread instance. 53 | # 54 | # @parameter io [IO] The IO object to use for communication with the parent. 55 | def initialize(io) 56 | @thread = ::Thread.current 57 | 58 | super 59 | end 60 | 61 | # Generate a hash representation of the thread. 62 | # 63 | # @returns [Hash] The thread as a hash, including `process_id`, `thread_id`, and `name`. 64 | def as_json(...) 65 | { 66 | process_id: ::Process.pid, 67 | thread_id: @thread.object_id, 68 | name: @thread.name, 69 | } 70 | end 71 | 72 | # Generate a JSON representation of the thread. 73 | # 74 | # @returns [String] The thread as JSON. 75 | def to_json(...) 76 | as_json.to_json(...) 77 | end 78 | 79 | # Set the name of the thread. 80 | # @parameter value [String] The name to set. 81 | def name= value 82 | @thread.name = value 83 | end 84 | 85 | # Get the name of the thread. 86 | # @returns [String] 87 | def name 88 | @thread.name 89 | end 90 | 91 | # Execute a child process using {::Process.spawn}. In order to simulate {::Process.exec}, an {Exit} instance is raised to propagage exit status. 92 | # This creates the illusion that this method does not return (normally). 93 | def exec(*arguments, ready: true, **options) 94 | if ready 95 | self.ready!(status: "(spawn)") 96 | else 97 | self.before_spawn(arguments, options) 98 | end 99 | 100 | begin 101 | pid = ::Process.spawn(*arguments, **options) 102 | ensure 103 | _, status = ::Process.wait2(pid) 104 | 105 | raise Exit, status 106 | end 107 | end 108 | end 109 | 110 | # Start a new child thread and execute the provided block in it. 111 | # 112 | # @parameter options [Hash] Additional options to to the new child instance. 113 | def self.fork(**options) 114 | self.new(**options) do |thread| 115 | ::Thread.new do 116 | # This could be a configuration option (see forked implementation too): 117 | ::Thread.handle_interrupt(SignalException => :immediate) do 118 | yield Instance.for(thread) 119 | end 120 | end 121 | end 122 | end 123 | 124 | # Initialize the thread. 125 | # 126 | # @parameter name [String] The name to use for the child thread. 127 | def initialize(name: nil) 128 | super() 129 | 130 | @status = nil 131 | 132 | @thread = yield(self) 133 | @thread.report_on_exception = false 134 | @thread.name = name 135 | 136 | @waiter = ::Thread.new do 137 | begin 138 | @thread.join 139 | rescue Exit => exit 140 | finished(exit.error) 141 | rescue Interrupt 142 | # Graceful shutdown. 143 | finished 144 | rescue Exception => error 145 | finished(error) 146 | else 147 | finished 148 | end 149 | end 150 | end 151 | 152 | # Convert the child process to a hash, suitable for serialization. 153 | # 154 | # @returns [Hash] The request as a hash. 155 | def as_json(...) 156 | { 157 | name: @thread.name, 158 | status: @status&.as_json, 159 | } 160 | end 161 | 162 | # Convert the request to JSON. 163 | # 164 | # @returns [String] The request as JSON. 165 | def to_json(...) 166 | as_json.to_json(...) 167 | end 168 | 169 | # Set the name of the thread. 170 | # @parameter value [String] The name to set. 171 | def name= value 172 | @thread.name = value 173 | end 174 | 175 | # Get the name of the thread. 176 | # @returns [String] 177 | def name 178 | @thread.name 179 | end 180 | 181 | # A human readable representation of the thread. 182 | # @returns [String] 183 | def to_s 184 | "\#<#{self.class} #{@thread.name}>" 185 | end 186 | 187 | # Invoke {#terminate!} and then {#wait} for the child thread to exit. 188 | def close 189 | self.terminate! 190 | self.wait 191 | ensure 192 | super 193 | end 194 | 195 | # Raise {Interrupt} in the child thread. 196 | def interrupt! 197 | @thread.raise(Interrupt) 198 | end 199 | 200 | # Raise {Terminate} in the child thread. 201 | def terminate! 202 | @thread.raise(Terminate) 203 | end 204 | 205 | # Invoke {Thread#kill} on the child thread. 206 | def kill! 207 | # Killing a thread does not raise an exception in the thread, so we need to handle the status here: 208 | @status = Status.new(:killed) 209 | 210 | @thread.kill 211 | end 212 | 213 | # Raise {Restart} in the child thread. 214 | def restart! 215 | @thread.raise(Restart) 216 | end 217 | 218 | # Wait for the thread to exit and return he exit status. 219 | # @returns [Status] 220 | def wait 221 | if @waiter 222 | @waiter.join 223 | @waiter = nil 224 | end 225 | 226 | return @status 227 | end 228 | 229 | # A pseudo exit-status wrapper. 230 | class Status 231 | # Initialise the status. 232 | # @parameter error [::Process::Status] The exit status of the child thread. 233 | def initialize(error = nil) 234 | @error = error 235 | end 236 | 237 | # Whether the status represents a successful outcome. 238 | # @returns [Boolean] 239 | def success? 240 | @error.nil? 241 | end 242 | 243 | # Convert the status to a hash, suitable for serialization. 244 | # 245 | # @returns [Boolean | String] If the status is an error, the error message is returned, otherwise `true`. 246 | def as_json(...) 247 | if @error 248 | @error.inspect 249 | else 250 | true 251 | end 252 | end 253 | 254 | # A human readable representation of the status. 255 | def to_s 256 | "\#<#{self.class} #{success? ? "success" : "failure"}>" 257 | end 258 | end 259 | 260 | protected 261 | 262 | # Invoked by the @waiter thread to indicate the outcome of the child thread. 263 | def finished(error = nil) 264 | if error 265 | Console.error(self) {error} 266 | end 267 | 268 | @status ||= Status.new(error) 269 | self.close_write 270 | end 271 | end 272 | 273 | # Start a named child thread and execute the provided block in it. 274 | # @parameter name [String] The name (title) of the child process. 275 | # @parameter block [Proc] The block to execute in the child process. 276 | def start(name, &block) 277 | Child.fork(name: name, &block) 278 | end 279 | end 280 | end 281 | end 282 | -------------------------------------------------------------------------------- /lib/async/container/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2024, by Samuel Williams. 5 | 6 | module Async 7 | module Container 8 | VERSION = "0.24.0" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/metrics/provider/async/container.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require_relative "container/generic" 7 | -------------------------------------------------------------------------------- /lib/metrics/provider/async/container/generic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require_relative "../../../../async/container/generic" 7 | require "metrics/provider" 8 | 9 | Metrics::Provider(Async::Container::Generic) do 10 | ASYNC_CONTAINER_GENERIC_HEALTH_CHECK_FAILED = Metrics.metric("async.container.generic.health_check_failed", :counter, description: "The number of health checks that failed.") 11 | 12 | protected def health_check_failed!(child, age_clock, health_check_timeout) 13 | ASYNC_CONTAINER_GENERIC_HEALTH_CHECK_FAILED.emit(1) 14 | 15 | super 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright, 2017-2025, by Samuel Williams. 4 | Copyright, 2019, by Yuji Yaginuma. 5 | Copyright, 2020, by Olle Jonsson. 6 | Copyright, 2020, by Juan Antonio Martín Lucas. 7 | Copyright, 2022, by Anton Sozontov. 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Async::Container 2 | 3 | Provides containers which implement parallelism for clients and servers. 4 | 5 | [![Development Status](https://github.com/socketry/async-container/workflows/Test/badge.svg)](https://github.com/socketry/async-container/actions?workflow=Test) 6 | 7 | ## Features 8 | 9 | - Supports multi-process, multi-thread and hybrid containers. 10 | - Automatic scalability based on physical hardware. 11 | - Direct integration with [systemd](https://www.freedesktop.org/software/systemd/man/sd_notify.html) using `$NOTIFY_SOCKET`. 12 | - Internal process readiness protocol for handling state changes. 13 | - Automatic restart of failed processes. 14 | 15 | ## Usage 16 | 17 | Please see the [project documentation](https://socketry.github.io/async-container/) for more details. 18 | 19 | - [Getting Started](https://socketry.github.io/async-container/guides/getting-started/index) - This guide explains how to use `async-container` to build basic scalable systems. 20 | 21 | ## Releases 22 | 23 | Please see the [project releases](https://socketry.github.io/async-container/releases/index) for all releases. 24 | 25 | ### v0.24.0 26 | 27 | - Add support for health check failure metrics. 28 | 29 | ### v0.23.0 30 | 31 | - [Add support for `NOTIFY_LOG` for Kubernetes readiness probes.](https://socketry.github.io/async-container/releases/index#add-support-for-notify_log-for-kubernetes-readiness-probes.) 32 | 33 | ### v0.21.0 34 | 35 | - Use `SIGKILL`/`Thread#kill` when the health check fails. In some cases, `SIGTERM` may not be sufficient to terminate a process because the signal can be ignored or the process may be in an uninterruptible state. 36 | 37 | ### v0.20.1 38 | 39 | - Fix compatibility between Async::Container::Hybrid and the health check. 40 | - Async::Container::Generic\#initialize passes unused arguments through to Async::Container::Group. 41 | 42 | ### v0.20.0 43 | 44 | - Improve container signal handling reliability by using `Thread.handle_interrupt` except at known safe points. 45 | - Improved logging when child process fails and container startup. 46 | - [Add `health_check_timeout` for detecting hung processes.](https://socketry.github.io/async-container/releases/index#add-health_check_timeout-for-detecting-hung-processes.) 47 | 48 | ## Contributing 49 | 50 | We welcome contributions to this project. 51 | 52 | 1. Fork it. 53 | 2. Create your feature branch (`git checkout -b my-new-feature`). 54 | 3. Commit your changes (`git commit -am 'Add some feature'`). 55 | 4. Push to the branch (`git push origin my-new-feature`). 56 | 5. Create new Pull Request. 57 | 58 | ### Developer Certificate of Origin 59 | 60 | In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. 61 | 62 | ### Community Guidelines 63 | 64 | This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. 65 | -------------------------------------------------------------------------------- /release.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 3 | ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK 4 | CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz 5 | MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd 6 | MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj 7 | bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB 8 | igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 9 | 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW 10 | sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE 11 | e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN 12 | XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss 13 | RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn 14 | tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM 15 | zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW 16 | xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O 17 | BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs 18 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs 19 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE 20 | cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl 21 | xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ 22 | c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp 23 | 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws 24 | JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP 25 | eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt 26 | Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 27 | voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /releases.md: -------------------------------------------------------------------------------- 1 | # Releases 2 | 3 | ## v0.24.0 4 | 5 | - Add support for health check failure metrics. 6 | 7 | ## v0.23.0 8 | 9 | ### Add support for `NOTIFY_LOG` for Kubernetes readiness probes. 10 | 11 | You may specify a `NOTIFY_LOG` environment variable to enable readiness logging to a log file. This can be used for Kubernetes readiness probes, e.g. 12 | 13 | ``` yaml 14 | containers: 15 | - name: falcon 16 | env: 17 | - name: NOTIFY_LOG 18 | value: "/tmp/notify.log" 19 | command: ["falcon", "host"] 20 | readinessProbe: 21 | exec: 22 | command: ["sh", "-c", "grep -q '\"ready\":true' /tmp/notify.log"] 23 | initialDelaySeconds: 5 24 | periodSeconds: 5 25 | failureThreshold: 12 26 | ``` 27 | 28 | ## v0.21.0 29 | 30 | - Use `SIGKILL`/`Thread#kill` when the health check fails. In some cases, `SIGTERM` may not be sufficient to terminate a process because the signal can be ignored or the process may be in an uninterruptible state. 31 | 32 | ## v0.20.1 33 | 34 | - Fix compatibility between {ruby Async::Container::Hybrid} and the health check. 35 | - {ruby Async::Container::Generic\#initialize} passes unused arguments through to {ruby Async::Container::Group}. 36 | 37 | ## v0.20.0 38 | 39 | - Improve container signal handling reliability by using `Thread.handle_interrupt` except at known safe points. 40 | - Improved logging when child process fails and container startup. 41 | 42 | ### Add `health_check_timeout` for detecting hung processes. 43 | 44 | In order to detect hung processes, a `health_check_timeout` can be specified when spawning children workers. If the health check does not complete within the specified timeout, the child process is killed. 45 | 46 | ``` ruby 47 | require "async/container" 48 | 49 | container = Async::Container.new 50 | 51 | container.run(count: 1, restart: true, health_check_timeout: 1) do |instance| 52 | while true 53 | # This example will fail sometimes: 54 | sleep(0.5 + rand) 55 | instance.ready! 56 | end 57 | end 58 | 59 | container.wait 60 | ``` 61 | 62 | If the health check does not complete within the specified timeout, the child process is killed: 63 | 64 | ``` 65 | 3.01s warn: Async::Container::Forked [oid=0x1340] [ec=0x1348] [pid=27100] [2025-02-20 13:24:55 +1300] 66 | | Child failed health check! 67 | | { 68 | | "child": { 69 | | "name": "Unnamed", 70 | | "pid": 27101, 71 | | "status": null 72 | | }, 73 | | "age": 1.0612829999881797, 74 | | "health_check_timeout": 1 75 | | } 76 | ``` 77 | -------------------------------------------------------------------------------- /test/async/container.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2024, by Samuel Williams. 5 | 6 | require "async/container" 7 | 8 | describe Async::Container do 9 | with ".processor_count" do 10 | it "can get processor count" do 11 | expect(Async::Container.processor_count).to be >= 1 12 | end 13 | 14 | it "can override the processor count" do 15 | env = {"ASYNC_CONTAINER_PROCESSOR_COUNT" => "8"} 16 | 17 | expect(Async::Container.processor_count(env)).to be == 8 18 | end 19 | 20 | it "fails on invalid processor count" do 21 | env = {"ASYNC_CONTAINER_PROCESSOR_COUNT" => "-1"} 22 | 23 | expect do 24 | Async::Container.processor_count(env) 25 | end.to raise_exception(RuntimeError, message: be =~ /Invalid processor count/) 26 | end 27 | end 28 | 29 | with ".new" do 30 | let(:container) {Async::Container.new} 31 | 32 | it "can get best container class" do 33 | expect(container).not.to be_nil 34 | container.stop 35 | end 36 | end 37 | 38 | with ".best" do 39 | it "can get the best container class" do 40 | expect(Async::Container.best_container_class).not.to be_nil 41 | end 42 | 43 | it "can get the best container class if fork is not available" do 44 | expect(subject).to receive(:fork?).and_return(false) 45 | 46 | expect(Async::Container.best_container_class).to be == Async::Container::Threaded 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/async/container/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "async/container/channel" 7 | 8 | describe Async::Container::Channel do 9 | let(:channel) {subject.new} 10 | 11 | after do 12 | @channel&.close 13 | end 14 | 15 | it "can send and receive" do 16 | channel.out.puts "Hello, World!" 17 | 18 | expect(channel.in.gets).to be == "Hello, World!\n" 19 | end 20 | 21 | it "can send and receive JSON" do 22 | channel.out.puts JSON.dump({hello: "world"}) 23 | 24 | expect(channel.receive).to be == {hello: "world"} 25 | end 26 | 27 | it "can receive invalid JSON" do 28 | channel.out.puts "Hello, World!" 29 | 30 | expect(channel.receive).to be == {line: "Hello, World!\n"} 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/async/container/controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | require "async/container/controller" 7 | require "async/container/controllers" 8 | 9 | describe Async::Container::Controller do 10 | let(:controller) {subject.new} 11 | 12 | with "#to_s" do 13 | it "can generate string representation" do 14 | expect(controller.to_s).to be == "Async::Container::Controller stopped" 15 | end 16 | end 17 | 18 | with "#reload" do 19 | it "can reuse keyed child" do 20 | input, output = IO.pipe 21 | 22 | controller.instance_variable_set(:@output, output) 23 | 24 | def controller.setup(container) 25 | container.spawn(key: "test") do |instance| 26 | instance.ready! 27 | 28 | @output.write(".") 29 | @output.flush 30 | 31 | sleep(0.2) 32 | end 33 | 34 | container.spawn do |instance| 35 | instance.ready! 36 | 37 | sleep(0.1) 38 | 39 | @output.write(",") 40 | @output.flush 41 | end 42 | end 43 | 44 | controller.start 45 | 46 | expect(controller.state_string).to be == "running" 47 | 48 | expect(input.read(2)).to be == ".," 49 | 50 | controller.reload 51 | 52 | expect(input.read(1)).to be == "," 53 | 54 | controller.wait 55 | end 56 | end 57 | 58 | with "#start" do 59 | it "can start up a container" do 60 | expect(controller).to receive(:setup) 61 | 62 | controller.start 63 | 64 | expect(controller).to be(:running?) 65 | expect(controller.container).not.to be_nil 66 | 67 | controller.stop 68 | 69 | expect(controller).not.to be(:running?) 70 | expect(controller.container).to be_nil 71 | end 72 | 73 | it "can spawn a reactor" do 74 | def controller.setup(container) 75 | container.async do |task| 76 | task.sleep 0.001 77 | end 78 | end 79 | 80 | controller.start 81 | 82 | statistics = controller.container.statistics 83 | 84 | expect(statistics.spawns).to be == 1 85 | 86 | controller.stop 87 | end 88 | 89 | it "propagates exceptions" do 90 | def controller.setup(container) 91 | raise "Boom!" 92 | end 93 | 94 | expect do 95 | controller.run 96 | end.to raise_exception(Async::Container::SetupError) 97 | end 98 | end 99 | 100 | with "graceful controller" do 101 | let(:controller_path) {Async::Container::Controllers.path_for("graceful")} 102 | 103 | let(:pipe) {IO.pipe} 104 | let(:input) {pipe.first} 105 | let(:output) {pipe.last} 106 | 107 | let(:pid) {@pid} 108 | 109 | def before 110 | @pid = Process.spawn("bundle", "exec", controller_path, out: output) 111 | output.close 112 | 113 | super 114 | end 115 | 116 | def after(error = nil) 117 | Process.kill(:TERM, @pid) 118 | Process.wait(@pid) 119 | 120 | super 121 | end 122 | 123 | it "has graceful shutdown" do 124 | expect(input.gets).to be == "Ready...\n" 125 | start_time = input.gets.to_f 126 | 127 | Process.kill(:INT, @pid) 128 | 129 | expect(input.gets).to be == "Graceful shutdown...\n" 130 | graceful_shutdown_time = input.gets.to_f 131 | 132 | expect(input.gets).to be == "Exiting...\n" 133 | exit_time = input.gets.to_f 134 | 135 | expect(exit_time - graceful_shutdown_time).to be >= 0.01 136 | end 137 | end 138 | 139 | with "bad controller" do 140 | let(:controller_path) {Async::Container::Controllers.path_for("bad")} 141 | 142 | let(:pipe) {IO.pipe} 143 | let(:input) {pipe.first} 144 | let(:output) {pipe.last} 145 | 146 | let(:pid) {@pid} 147 | 148 | def before 149 | @pid = Process.spawn("bundle", "exec", controller_path, out: output) 150 | output.close 151 | 152 | super 153 | end 154 | 155 | def after(error = nil) 156 | Process.kill(:TERM, @pid) 157 | Process.wait(@pid) 158 | 159 | super 160 | end 161 | 162 | it "fails to start" do 163 | expect(input.gets).to be == "Ready...\n" 164 | 165 | Process.kill(:INT, @pid) 166 | 167 | expect(input.gets).to be == "Exiting...\n" 168 | end 169 | end 170 | 171 | with "signals" do 172 | let(:controller_path) {Async::Container::Controllers.path_for("dots")} 173 | 174 | let(:pipe) {IO.pipe} 175 | let(:input) {pipe.first} 176 | let(:output) {pipe.last} 177 | 178 | let(:pid) {@pid} 179 | 180 | def before 181 | @pid = Process.spawn("bundle", "exec", controller_path, out: output) 182 | output.close 183 | 184 | super 185 | end 186 | 187 | def after(error = nil) 188 | Process.kill(:TERM, @pid) 189 | Process.wait(@pid) 190 | 191 | super 192 | end 193 | 194 | it "restarts children when receiving SIGHUP" do 195 | expect(input.read(1)).to be == "." 196 | 197 | Process.kill(:HUP, pid) 198 | 199 | expect(input.read(2)).to be == "I." 200 | end 201 | 202 | it "exits gracefully when receiving SIGINT" do 203 | expect(input.read(1)).to be == "." 204 | 205 | Process.kill(:INT, pid) 206 | 207 | expect(input.read).to be == "I" 208 | end 209 | 210 | it "exits gracefully when receiving SIGTERM" do 211 | expect(input.read(1)).to be == "." 212 | 213 | Process.kill(:TERM, pid) 214 | 215 | expect(input.read).to be == "T" 216 | end 217 | end 218 | 219 | with "working directory" do 220 | let(:controller_path) {Async::Container::Controllers.path_for("working_directory")} 221 | 222 | it "can change working directory" do 223 | pipe = IO.pipe 224 | 225 | pid = Process.spawn("bundle", "exec", controller_path, out: pipe.last) 226 | pipe.last.close 227 | 228 | expect(pipe.first.gets(chomp: true)).to be == "/" 229 | ensure 230 | Process.kill(:INT, pid) if pid 231 | end 232 | end 233 | end 234 | -------------------------------------------------------------------------------- /test/async/container/forked.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | # Copyright, 2020, by Olle Jonsson. 6 | 7 | require "async/container/best" 8 | require "async/container/forked" 9 | require "async/container/a_container" 10 | 11 | describe Async::Container::Forked do 12 | let(:container) {subject.new} 13 | 14 | it_behaves_like Async::Container::AContainer 15 | 16 | it "can restart child" do 17 | trigger = IO.pipe 18 | pids = IO.pipe 19 | 20 | thread = Thread.new do 21 | container.async(restart: true) do 22 | trigger.first.gets 23 | pids.last.puts Process.pid.to_s 24 | end 25 | 26 | container.wait 27 | end 28 | 29 | 3.times do 30 | trigger.last.puts "die" 31 | _child_pid = pids.first.gets 32 | end 33 | 34 | thread.kill 35 | thread.join 36 | 37 | expect(container.statistics.spawns).to be == 1 38 | expect(container.statistics.restarts).to be == 2 39 | end 40 | 41 | it "can handle interrupts" do 42 | finished = IO.pipe 43 | interrupted = IO.pipe 44 | 45 | container.spawn(restart: true) do |instance| 46 | Thread.handle_interrupt(Interrupt => :never) do 47 | instance.ready! 48 | 49 | finished.first.gets 50 | rescue ::Interrupt 51 | interrupted.last.puts "incorrectly interrupted" 52 | end 53 | rescue ::Interrupt 54 | interrupted.last.puts "correctly interrupted" 55 | end 56 | 57 | container.wait_until_ready 58 | 59 | container.group.interrupt 60 | sleep(0.001) 61 | finished.last.puts "finished" 62 | 63 | expect(interrupted.first.gets).to be == "correctly interrupted\n" 64 | 65 | container.stop 66 | end 67 | 68 | it "should be multiprocess" do 69 | expect(subject).to be(:multiprocess?) 70 | end 71 | end if Async::Container.fork? 72 | -------------------------------------------------------------------------------- /test/async/container/hybrid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require "async/container/hybrid" 7 | require "async/container/best" 8 | require "async/container/a_container" 9 | 10 | describe Async::Container::Hybrid do 11 | it_behaves_like Async::Container::AContainer 12 | 13 | it "should be multiprocess" do 14 | expect(subject).to be(:multiprocess?) 15 | end 16 | end if Async::Container.fork? 17 | -------------------------------------------------------------------------------- /test/async/container/notify.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require "async/container/controller" 7 | require "async/container/notify/server" 8 | 9 | require "async" 10 | 11 | describe Async::Container::Notify do 12 | let(:server) {subject::Server.open} 13 | let(:notify_socket) {server.path} 14 | let(:client) {subject::Socket.new(notify_socket)} 15 | 16 | it "can send and receive messages" do 17 | context = server.bind 18 | 19 | client.send(true: true, false: false, hello: "world") 20 | 21 | message = context.receive 22 | 23 | expect(message).to be == {true: true, false: false, hello: "world"} 24 | end 25 | 26 | with "#ready!" do 27 | it "should send message" do 28 | begin 29 | context = server.bind 30 | 31 | pid = fork do 32 | client.ready! 33 | end 34 | 35 | messages = [] 36 | 37 | Sync do 38 | context.receive do |message, address| 39 | messages << message 40 | break 41 | end 42 | end 43 | 44 | expect(messages.last).to have_keys( 45 | ready: be == true 46 | ) 47 | ensure 48 | context&.close 49 | Process.wait(pid) if pid 50 | end 51 | end 52 | end 53 | 54 | with "#send" do 55 | it "sends message" do 56 | context = server.bind 57 | 58 | client.send(hello: "world") 59 | 60 | message = context.receive 61 | 62 | expect(message).to be == {hello: "world"} 63 | end 64 | 65 | it "fails if the message is too big" do 66 | context = server.bind 67 | 68 | expect do 69 | client.send(test: "x" * (subject::Socket::MAXIMUM_MESSAGE_SIZE+1)) 70 | end.to raise_exception(ArgumentError, message: be =~ /Message length \d+ exceeds \d+/) 71 | end 72 | end 73 | 74 | with "#stopping!" do 75 | it "sends stopping message" do 76 | context = server.bind 77 | 78 | client.stopping! 79 | 80 | message = context.receive 81 | 82 | expect(message).to have_keys( 83 | stopping: be == true 84 | ) 85 | end 86 | end 87 | 88 | with "#error!" do 89 | it "sends error message" do 90 | context = server.bind 91 | 92 | client.error!("Boom!") 93 | 94 | message = context.receive 95 | 96 | expect(message).to have_keys( 97 | status: be == "Boom!", 98 | errno: be == -1, 99 | ) 100 | end 101 | end 102 | end if Async::Container.fork? 103 | -------------------------------------------------------------------------------- /test/async/container/notify/log.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | # Copyright, 2020, by Olle Jonsson. 6 | 7 | require "async/container/controller" 8 | require "async/container/controllers" 9 | 10 | require "tmpdir" 11 | 12 | describe Async::Container::Notify::Pipe do 13 | let(:notify_script) {Async::Container::Controllers.path_for("notify")} 14 | let(:notify_log) {File.expand_path("notify-#{::Process.pid}-#{SecureRandom.hex(8)}.log", Dir.tmpdir)} 15 | 16 | it "receives notification of child status" do 17 | system({"NOTIFY_LOG" => notify_log}, "bundle", "exec", notify_script) 18 | 19 | lines = File.readlines(notify_log).map{|line| JSON.parse(line)} 20 | 21 | expect(lines.last).to have_keys( 22 | "ready" => be == true, 23 | "size" => be > 0, 24 | ) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/async/container/notify/pipe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | # Copyright, 2020, by Olle Jonsson. 6 | 7 | require "async/container/controller" 8 | require "async/container/controllers" 9 | 10 | describe Async::Container::Notify::Pipe do 11 | let(:notify_script) {Async::Container::Controllers.path_for("notify")} 12 | 13 | it "receives notification of child status" do 14 | container = Async::Container.new 15 | 16 | container.spawn(restart: false) do |instance| 17 | instance.exec( 18 | "bundle", "exec", 19 | notify_script, ready: false 20 | ) 21 | end 22 | 23 | # Wait for the state to be updated by the child process: 24 | container.sleep 25 | 26 | _child, state = container.state.first 27 | expect(state).to be == {status: "Initializing controller..."} 28 | 29 | container.wait 30 | 31 | expect(container.statistics).to have_attributes(failures: be == 0) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/async/container/notify/socket.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | # Copyright, 2020, by Olle Jonsson. 6 | 7 | require "async/container/notify/socket" 8 | 9 | describe Async::Container::Notify::Socket do 10 | with ".open!" do 11 | it "can open a socket" do 12 | socket = subject.open!({subject::NOTIFY_SOCKET => "test"}) 13 | 14 | expect(socket).to have_attributes(path: be == "test") 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/async/container/statistics.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "async/container/statistics" 7 | 8 | describe Async::Container::Statistics do 9 | let(:statistics) {subject.new} 10 | 11 | with "#spawn!" do 12 | it "can count spawns" do 13 | expect(statistics.spawns).to be == 0 14 | 15 | statistics.spawn! 16 | 17 | expect(statistics.spawns).to be == 1 18 | end 19 | end 20 | 21 | with "#restart!" do 22 | it "can count restarts" do 23 | expect(statistics.restarts).to be == 0 24 | 25 | statistics.restart! 26 | 27 | expect(statistics.restarts).to be == 1 28 | end 29 | end 30 | 31 | with "#failure!" do 32 | it "can count failures" do 33 | expect(statistics.failures).to be == 0 34 | 35 | statistics.failure! 36 | 37 | expect(statistics.failures).to be == 1 38 | end 39 | end 40 | 41 | with "#failed?" do 42 | it "can check for failures" do 43 | expect(statistics).not.to be(:failed?) 44 | 45 | statistics.failure! 46 | 47 | expect(statistics).to be(:failed?) 48 | end 49 | end 50 | 51 | with "#<<" do 52 | it "can append statistics" do 53 | other = subject.new 54 | 55 | other.spawn! 56 | other.restart! 57 | other.failure! 58 | 59 | statistics << other 60 | 61 | expect(statistics.spawns).to be == 1 62 | expect(statistics.restarts).to be == 1 63 | expect(statistics.failures).to be == 1 64 | end 65 | end 66 | end -------------------------------------------------------------------------------- /test/async/container/threaded.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "async/container/threaded" 7 | require "async/container/a_container" 8 | 9 | describe Async::Container::Threaded do 10 | it_behaves_like Async::Container::AContainer 11 | 12 | it "should not be multiprocess" do 13 | expect(subject).not.to be(:multiprocess?) 14 | end 15 | end 16 | --------------------------------------------------------------------------------