Skip to content
RubcraftPublic

About

Hardened Singleton and keyed Multiton patterns for Ruby

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Singulus

CI Gem Version

Singulus adds configurable Ruby-level hardening around two instance-uniqueness patterns:

  • Singulus::Singleton — one instance per class and Ruby process.
  • Singulus::Multiton — one registered instance per class and normalized key.

It builds on Ruby's standard Singleton for the classic Singleton implementation and provides its own thread-safe keyed registry for Multiton.

Singulus exposes one supported entry point:

require "singulus"

The supported root API is intentionally small:

  • Singulus::Singleton
  • Singulus::Multiton
  • Singulus::Error as the public base exception
  • Singulus.configure, Singulus.configuration, and Singulus.reset_configuration!
  • Singulus::VERSION

All implementation classes, guards, configuration types, and specialized errors live behind a private Singulus::Internal namespace and are not public constants. No pre-release compatibility entry points are shipped.

Security boundary

This gem is aggressive Ruby-level hardening, not a sandbox. Code executing in the same Ruby process can reopen core classes, use native extensions, replace guards, or otherwise modify the runtime. :runtime closes many common Method/UnboundMethod bypasses, but it cannot create a cryptographic or process-isolation boundary.

Modes

Singulus.configure do |config|
  config.default_mode = :strict
end

Available modes:

  • :standard — minimal policy. Classic Singleton delegates to Ruby Singleton; Multiton keeps constructors private but does not install local hardening policy.
  • :strict — constructor reflection guards, mutation protection, duplication protection, inheritance restrictions, and constructor sealing for classic Singleton.
  • :runtime — everything in :strict, plus process-wide Method and UnboundMethod guards that activate only for classes marked as runtime-hardened.

The default is :strict.

Configure directly from include

Singleton and Multiton also expose .with(...), which creates an independent configured inclusion module. It does not mutate the shared Singulus::Singleton or Singulus::Multiton module.

class Configuration
  include Singulus::Singleton.with(:strict)
end

class RuntimeConfiguration
  include Singulus::Singleton.with(mode: :runtime)
end

class TenantConfiguration
  include Singulus::Multiton.with(
    :runtime,
    retention: :lru,
    max_size: 5_000
  )
end

The existing two-step DSL remains supported and equivalent:

include Singulus::Singleton
singulus mode: :strict

Classic Singleton

require "singulus"

class Configuration
  include Singulus::Singleton.with(:runtime)
end

Configuration.instance.equal?(Configuration.instance)
# => true

In hardened modes, direct constructor access is guarded from the moment the module is included, including attempts to change constructor visibility. Common reflection routes, duplication, constructor redefinition, and inheritance are rejected. After the first instance, classic Singleton also seals its constructor lookup path.

Multiton: one instance per identifier

class TenantConfiguration
  include Singulus::Multiton.with(:strict)

  def initialize(tenant_id)
    @tenant_id = tenant_id
  end
end

one = TenantConfiguration.instance_for(10)
two = TenantConfiguration.instance_for(10)
other = TenantConfiguration.instance_for(20)

one.equal?(two)   # => true
one.equal?(other) # => false

instance_for(identifier, *args, **kwargs) passes the original identifier as the first initializer argument. Additional initializer arguments are used only when an instance for that normalized key does not already exist.

Stable tenant keys

Prefer immutable identifiers such as integers, symbols, UUID strings, or normalized composite keys. Strings, arrays, and hashes returned as keys are defensively duplicated/frozen recursively where practical.

For domain objects, define a key normalizer:

class TenantConfiguration
  include Singulus::Multiton

  multiton_key { |tenant| tenant.id }

  def initialize(tenant)
    @tenant = tenant
  end
end

This avoids retaining an ActiveRecord object itself as the registry key.

Multiton lifecycle

TenantConfiguration.instance?(10)
TenantConfiguration.instance_count
TenantConfiguration.instance_keys
TenantConfiguration.delete_instance(10)
TenantConfiguration.clear_instances

delete_instance and clear_instances explicitly release registry ownership. If another part of the application still holds the old object, a later instance_for can create a new object for the same key. Therefore uniqueness is registry/lifecycle scoped, not a claim that no second live Ruby object can ever exist after explicit release or eviction.

Retention strategies

The default is strict registry retention:

class TenantConfiguration
  include Singulus::Multiton
  multiton_retention :forever
end

Supported strategies:

:forever

Keeps each registered instance until delete_instance/clear_instances or process exit. This is the strongest per-key identity guarantee supplied by Multiton.

:lru

multiton_retention :lru, max_size: 5_000

Bounds memory by evicting the least recently used registry entry. Once evicted, the same key can create a new instance later.

:ttl

multiton_retention :ttl, ttl: 300

Keeps an instance for a fixed TTL measured with Process::CLOCK_MONOTONIC. Expiration is based on creation time, not sliding access time. After expiration, the same key can create a new instance.

:bounded

multiton_retention :bounded, ttl: 300, max_size: 5_000

Combines TTL expiration with an LRU capacity bound. Entries are evicted when they expire or when the registry exceeds max_size. Use this instead of passing max_size to :ttl or ttl to :lru; those ambiguous configurations are rejected.

:weak

multiton_retention :weak

Stores weak references. Singulus reuses the same object while it remains alive elsewhere, but the garbage collector may reclaim it when no strong references remain. A later instance_for can therefore create a new instance. :weak accepts neither ttl nor max_size.

Strategy ttl max_size Identity scope
:forever no no until explicit release/process exit
:lru no required while retained by capacity
:ttl required no until expiration
:bounded required required until expiration or capacity eviction
:weak no no while the object remains strongly referenced

multiton_key and retention configuration are locked while the registry contains instances. Call clear_instances before changing them deliberately.

Runtime hardening

Runtime mode additionally guards common bypasses involving:

  • Method#call
  • Method#[]
  • Method#to_proc
  • Method#>> / Method#<<
  • UnboundMethod#bind
  • UnboundMethod#bind_call
  • captured reflection gateways such as Object#method and BasicObject#__send__

The patches are installed process-wide once required, but their rejection logic only activates for classes marked :runtime.

Enable runtime mode as early as possible during boot:

Singulus.configure do |config|
  config.default_mode = :runtime
end

For Rails, place this in an early initializer before code that might capture constructor references.

A Proc created from a constructor Method before Singulus runtime hardening is enabled cannot be retroactively identified as constructor-derived. Enable :runtime during boot, before untrusted or extension code can capture such references.

Development

bundle install
bundle exec rubocop --parallel
bundle exec rspec
bundle exec bundler-audit check --update
bundle exec rake build

SimpleCov enforces line and branch coverage, while CI tests supported Ruby versions independently from RuboCop.

API documentation

Generate the YARD reference locally:

bundle install
bundle exec rake yard

Open doc/index.html in a browser. The reference includes configuration, include-time options, errors, and the class methods installed by Singleton and Multiton. Installed methods appear on their pattern module's page; call these on your including class, while calling .with on the pattern module. The configuration object's reader/writer contract is documented under Singulus.configuration; its concrete class remains private.

.yardopts excludes private implementation details. Generated HTML and the YARD cache are ignored by Git. Documentation generation fails on YARD warnings and runs in the CI quality job and bundle exec rake ci.

Releasing

Repository initialization, version-control operations, and release-tag creation are managed by the Rubcraft Toolkit. Singulus itself does not prescribe or duplicate those commands.

The repository keeps only the CI/publish boundary: .github/workflows/release.yml reacts to a version tag produced by the Toolkit, verifies that it matches Singulus::VERSION, runs quality checks, builds the gem, and publishes through RubyGems Trusted Publishing (OIDC). Configure a GitHub environment named release and the matching RubyGems Trusted Publisher before the first release.

About

Hardened Singleton and keyed Multiton patterns for Ruby

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages