| File: | blib/lib/Object/Configure.pm |
| Coverage: | 93.4% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Object::Configure; | |||||
| 2 | ||||||
| 3 | 39 39 39 | 3868064 22 550 | use strict; | |||
| 4 | 39 39 39 | 60 30 840 | use warnings; | |||
| 5 | ||||||
| 6 | 39 39 39 | 70 32 951 | use Carp; | |||
| 7 | 39 39 39 | 11487 983196 777 | use Config::Abstraction 0.38; | |||
| 8 | 39 39 39 | 121 33 500 | use File::Spec; | |||
| 9 | 39 39 39 | 10751 965229 665 | use Log::Abstraction 0.26; | |||
| 10 | 39 39 39 | 170 38 151 | use mro; | |||
| 11 | 39 39 39 | 463 246 633 | use Params::Get 0.13; | |||
| 12 | 39 39 39 | 64 29 591 | use Readonly; | |||
| 13 | 39 39 39 | 61 36 491 | use Return::Set; | |||
| 14 | 39 39 39 | 58 23 860 | use List::Util qw(any); | |||
| 15 | 39 39 39 | 68 18 782 | use Scalar::Util qw(blessed weaken); | |||
| 16 | 39 39 39 | 85 26 169 | use Time::HiRes qw(time); | |||
| 17 | 39 39 39 | 9016 102638 1006 | use File::stat; | |||
| 18 | 39 39 39 | 113 44 111 | use POSIX qw(WNOHANG); | |||
| 19 | ||||||
| 20 | # Avoid magic literals scattered across hot paths and signal handlers. | |||||
| 21 | # Centralising here makes global search-replace safe and self-documents intent. | |||||
| 22 | Readonly my $OS_WINDOWS => 'MSWin32'; | |||||
| 23 | Readonly my $LOGGER_NULL => 'NULL'; | |||||
| 24 | Readonly my $SIG_DEFAULT => 'DEFAULT'; | |||||
| 25 | Readonly my $SIG_IGNORE => 'IGNORE'; | |||||
| 26 | Readonly my $POLL_SLEEP => 0.1; # seconds between waitpid polls in disable_hot_reload | |||||
| 27 | Readonly my $KILL_TIMEOUT => 5; # seconds before SIGKILL escalation after SIGTERM | |||||
| 28 | Readonly my $DEFAULT_INTERVAL => 10; # default hot-reload poll interval in seconds | |||||
| 29 | ||||||
| 30 | # Matches any path segment that is exactly ".." (anchored start, slash, or end). | |||||
| 31 | # Catches: ../foo foo/../bar foo/.. and leading ../ | |||||
| 32 | # Used in two places; kept as a constant so both guards are always in sync. | |||||
| 33 | Readonly my $RE_PATH_TRAVERSAL => qr{(?:\A|/)\.\.(?:/|\z)}; | |||||
| 34 | ||||||
| 35 | # Global registry â intentionally package-level so that the END block and | |||||
| 36 | # signal handlers installed in one call site share state with all others. | |||||
| 37 | # This is a deliberate singleton design; see LIMITATIONS for the trade-offs. | |||||
| 38 | our %_object_registry = (); | |||||
| 39 | our %_config_watchers = (); | |||||
| 40 | our %_config_file_stats = (); | |||||
| 41 | ||||||
| 42 | # Memoization caches â keyed by inputs; valid for the process lifetime because | |||||
| 43 | # Perl @ISA hierarchies and filesystem layouts are stable after module load. | |||||
| 44 | # Both caches include undef sentinels (use exists, not defined, to check hits). | |||||
| 45 | our %_chain_cache = (); # class => [UNIVERSAL, ..., Class] (base-first) | |||||
| 46 | our %_find_cache = (); # "\0"-joined key => file path or undef | |||||
| 47 | ||||||
| 48 | # Saved before we install our SIGUSR1 handler so we can chain and restore it. | |||||
| 49 | our $_original_usr1_handler; | |||||
| 50 | ||||||
| 51 - 59 | =head1 NAME Object::Configure - Runtime Configuration for an Object =head1 VERSION 0.24 =cut | |||||
| 60 | ||||||
| 61 | our $VERSION = 0.24; | |||||
| 62 | ||||||
| 63 - 545 | =head1 DESCRIPTION
C<Object::Configure> injects runtime configuration and logging into Perl class
constructors. It is a thin layer on top of L<Config::Abstraction> (reads config
files and environment variables) and L<Log::Abstraction> (logging).
Call C<configure($class, \%params)> at the start of your C<new()> method. It:
=over 4
=item 1. Walks C<@ISA> and finds config files for every class in the inheritance chain.
=item 2. Merges those files, then overlays environment variables named C<ClassName__key>.
=item 3. Creates a L<Log::Abstraction> logger and stores it in C<$params-E<gt>{logger}>.
=item 4. Returns a hashref ready to pass to C<bless>.
=back
The module also provides optional hot-reload support: a background process watches
config files and sends C<SIGUSR1> to trigger an in-place update of registered objects
without restarting the application.
B<Hot reload is not supported on Windows> (C<SIGUSR1> does not exist there).
=head1 SYNOPSIS
=head2 Example 1: Add configurable logging to your own class
package My::Module;
use Object::Configure;
sub new {
my ($class, %args) = @_;
# configure() reads config files + env vars, sets up a logger,
# and returns a hashref ready to bless.
my $params = Object::Configure::configure($class, \%args);
return bless $params, $class;
}
sub do_work {
my $self = shift;
$self->{logger}->info('Starting do_work');
# ...
}
# Usage -- reads ~/.conf/my-module.yml if it exists:
my $obj = My::Module->new(config_file => '/etc/myapp/my-module.yml');
$obj->do_work;
=head2 Example 2: Configure a third-party class you cannot modify
use Object::Configure;
# Wrap LWP::UserAgent so it reads its settings from a YAML file.
my $ua = Object::Configure::instantiate(
class => 'LWP::UserAgent',
config_file => '/etc/myapp/lwp.yml',
timeout => 30, # fallback if the file has no timeout key
);
$ua->get('https://example.com');
=head2 Example 3: Multi-level inheritance -- config files merge automatically
# ~/.conf/my-base-class.yml
# My__Base__Class:
# timeout: 30
# retries: 3
# ~/.conf/my-child-class.yml
# My__Child__Class:
# timeout: 60 # overrides base; retries:3 is inherited
package My::Child::Class;
our @ISA = ('My::Base::Class');
use Object::Configure;
sub new {
my ($class, %args) = @_;
# Walks @ISA, merges base config then child config.
# Result: timeout=60, retries=3
my $params = Object::Configure::configure($class, \%args);
return bless $params, $class;
}
=head2 Example 4: Hot reload -- objects update when the config file changes
package My::Service;
use Object::Configure;
sub new {
my ($class, %args) = @_;
my $params = Object::Configure::configure($class, \%args);
my $self = bless $params, $class;
# Register so reload_config() updates $self in-place on file change.
Object::Configure::register_object($class, $self)
if $params->{_config_file};
return $self;
}
package main;
my $svc = My::Service->new(config_file => '/etc/myapp/service.yml');
# Fork a background watcher; it sends SIGUSR1 when the file changes.
Object::Configure::enable_hot_reload(
interval => 5,
callback => sub { print "Config reloaded at ", scalar localtime, "\n" },
);
while (1) { sleep 1 } # event loop
Object::Configure::disable_hot_reload(); # clean shutdown
=head2 Example 5: Override settings with environment variables (no file needed)
# Shell:
# export My__Module__log_level=debug
# export My__Module__timeout=120
package My::Module;
use Object::Configure;
sub new {
my ($class, %args) = @_;
# configure() picks up My__Module__* env vars automatically.
my $params = Object::Configure::configure($class, \%args);
return bless $params, $class;
}
my $obj = My::Module->new;
# $obj->{log_level} eq 'debug' $obj->{timeout} == 120
=head1 CONFIGURATION
=head2 Config file naming
The config file name is derived from the class name by lowercasing it and replacing
C<::> with hyphens (C<->):
My::Parent::Class => my-parent-class.yml
The file is searched for in the directory of the C<config_file> argument, then in
any directories listed in C<config_dirs>.
=head2 Section key naming inside the config file
Inside the YAML (or JSON or conf) file, the section key uses double underscores in
place of C<::>:
# my-parent-class.yml
---
My__Parent__Class:
timeout: 30
retries: 3
=head2 Configuration resolution order
The following sources are merged from I<lowest> to I<highest> priority. A value
from a higher-priority source always wins over a lower-priority one.
=over 4
=item 1. B<Caller-supplied params> -- the hashref you pass to C<configure()>. Despite
the name "defaults", these are the I<lowest> priority and are overridden by everything
else.
=item 2. B<UNIVERSAL section> -- the C<UNIVERSAL:> block in C<universal.yml> (or
C<universal.conf>, C<universal.json>) in your config directories, if the file exists.
=item 3. B<Ancestor class config files> -- walked base-first through C<@ISA> using
the class's Method Resolution Order (MRO).
=item 4. B<Primary config file> -- the file named by the C<config_file> argument.
=item 5. B<Environment variables> -- named C<ClassName__key=value>, where C<::> in
the class name is replaced by C<__>. These are the I<highest> priority.
=back
=head2 UNIVERSAL configuration
If you create C<universal.yml> in your config directory with a C<UNIVERSAL:> section,
those settings apply to every class that uses C<Object::Configure> unless a
more-specific source overrides them:
# ~/.conf/universal.yml
---
UNIVERSAL:
timeout: 30
logger:
level: warning
=head2 Logging
C<configure()> always sets C<$params-E<gt>{logger}> to a L<Log::Abstraction>
instance. You can control it by passing a C<logger> key:
# Arrayref: messages are captured into @log (protected from merge override)
configure($class, { logger => \@log });
# Hashref: options forwarded to Log::Abstraction::new()
configure($class, { logger => { level => 'debug', file => '/var/log/app.log' } });
# String 'NULL': disables all logging
configure($class, { logger => 'NULL' });
# Existing Log::Abstraction object: used as-is
configure($class, { logger => $my_logger });
B<Note:> only arrayref loggers are stashed before the config merge and are guaranteed
to survive it. C<'NULL'> and blessed logger objects can be overridden by a
C<UNIVERSAL:> section in C<universal.yml>. See L</COMMON PITFALLS>.
=head2 Environment variable format
Environment variable names are constructed as:
ClassName__key
where C<::> in the class name is replaced by two underscores.
export My__Module__timeout=60
export My__Module__logger__level=debug
=head1 HOT RELOAD
Hot reload lets you edit a config file and have all live objects update themselves
without restarting the program.
=head2 How it works
=over 4
=item 1. Your constructor calls C<register_object($class, $self)> to opt in.
=item 2. Your main program calls C<enable_hot_reload()> to fork a background watcher.
=item 3. The watcher polls config files every C<interval> seconds and sends
C<SIGUSR1> to the parent when it detects a change.
=item 4. The C<SIGUSR1> handler calls C<reload_config()>, which re-reads files and
updates every registered object in-place.
=item 5. Your main program calls C<disable_hot_reload()> on shutdown.
=back
Private keys (those whose names start with C<_>) are never overwritten during
reload, so internal bookkeeping is safe.
B<Hot reload is not supported on Windows.>
=head1 COMMON PITFALLS
=head2 Only arrayref loggers survive the config merge
If you pass C<logger =E<gt> 'NULL'> or C<logger =E<gt> $existing_logger>, the
C<UNIVERSAL:> section in C<universal.yml> (or a site-local config file) can
silently override your logger during the config merge.
Only arrayref loggers are stashed before the merge and are guaranteed to survive:
# Protected -- will NOT be overridden by universal.yml
configure($class, { logger => \@captured });
# NOT protected -- universal.yml can override these
configure($class, { logger => 'NULL' });
configure($class, { logger => $existing_obj });
=head2 Caller-supplied keys are the LOWEST priority
Despite being called "defaults", keys you pass directly to C<configure()> are
overridden by config files and environment variables:
# My__Module__timeout=60 is set in the shell.
# $params->{timeout} will be 60, not 30.
configure('My::Module', { timeout => 30 });
Use caller-supplied keys only as a last-resort fallback.
=head2 register_object() pushes; it does not replace
Calling C<register_object()> twice for the same class registers B<two> entries.
Both objects receive updates on every reload. The second call does not remove the
first.
Object::Configure::register_object('My::Class', $obj_a);
Object::Configure::register_object('My::Class', $obj_b);
# reload_config() now updates BOTH $obj_a and $obj_b
=head2 Private keys are never updated on hot reload
Any key whose name begins with C<_> is skipped during C<reload_config()>.
A config key named C<_my_setting> in your YAML file will be ignored at reload time.
=head2 The 'class' key appears on objects created by instantiate()
C<instantiate()> intentionally leaves the C<class> key in the hashref passed to
C<$class-E<gt>new()> as a debugging aid. Your object will have a C<class>
attribute set to the class name:
my $obj = Object::Configure::instantiate(class => 'My::Thing', ...);
print $obj->{class}; # prints 'My::Thing'
=head2 Memoization caches are not invalidated during a run
C<_get_inheritance_chain()> and C<_find_class_config_file()> cache their results
for the lifetime of the process. If you alter C<@ISA> or add config files after
the first C<configure()> call for a given class, the cache returns stale results.
=head2 disable_hot_reload() blocks for up to five seconds
It waits for the watcher process to exit (SIGTERM first, then SIGKILL). Do not
call it from inside a signal handler or a timing-sensitive loop.
=head2 config_file must be developer-controlled
The C<config_file> path is validated against directory traversal (C<../>) but is
not otherwise restricted. It must always be a developer-supplied path, never raw
user input.
=head1 SUBROUTINES/METHODS
=head2 configure($class, \%params)
Merge configuration for C<$class> from all available sources and return a hashref
ready to pass to C<bless>. This is the core function; call it at the start of your
C<new()> method.
=head3 Arguments
=over 4
=item * C<$class> (Required, string)
The fully-qualified Perl class name to configure (e.g., C<'My::Module'>). Must
start with a letter or underscore; each C<::>-separated component must also start
with a letter or underscore. Digits, newlines, and shell metacharacters are rejected.
=item * C<\%params> (Optional, hashref; defaults to C<{}>)
Caller-supplied values. These have the I<lowest> priority and are overridden by
config files and environment variables. Recognized special keys:
=over 4
=item * C<config_file> (string, optional) -- path to the primary YAML/JSON/conf file.
=item * C<config_dirs> (arrayref of strings, optional) -- additional directories to
search when C<config_file> is a bare filename with no directory component.
=item * C<logger> (various, optional) -- see L</Logging>.
=item * C<carp_on_warn> (boolean, optional, default 0) -- if true, the logger uses
C<Carp::carp> instead of C<warn>.
=item * C<croak_on_error> (boolean, optional, default 1) -- if true, the logger uses
C<Carp::croak> instead of C<die>.
=back
=back
=head3 Returns
A hashref containing all merged configuration keys plus:
=over 4
=item * C<logger> -- a L<Log::Abstraction> instance, or the string C<'NULL'>.
=item * C<_config_file> -- path of the primary config file (only if one was loaded).
=item * C<_config_files> -- arrayref of every config file that was loaded (only if
at least one was loaded).
=back
=head3 Error messages
=over 4
=item * C<Object::Configure: configure: what class do you want to configure?> --
C<$class> was C<undef> or an empty string. Pass C<ref($self)> or C<__PACKAGE__>.
=item * C<Object::Configure: configure: invalid class name (must be a valid Perl package name): CLASS> --
C<$class> contains characters not allowed in a Perl package name (for example, a
digit as the first character of a C<::>-component, a newline, or a semicolon).
=item * C<CLASS: config_file contains path traversal sequences: FILE> --
C<config_file> contains a C<..> segment (e.g., C<../../etc/passwd>). The
C<config_file> argument must always be a developer-controlled path.
=item * C<CLASS: FILE: OS-ERROR> --
C<config_file> is not readable and no C<config_dirs> were supplied. Check file
permissions or add C<config_dirs>.
=item * C<Warning: Can't load configuration from FILE: DETAIL> --
L<Config::Abstraction> rejected the file (typically a YAML/JSON syntax error).
This is a warning, not fatal; C<configure()> continues with an empty config.
=item * C<Object::Configure: config_path contains path traversal sequences: PATH> --
An environment variable set a C<config_path> value containing C<..>.
=back
=head3 API Specification
=head4 Input
schema => {
class => {
type => 'string',
required => 1,
description => 'Fully-qualified Perl class name',
pattern => qr/\A[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*\z/,
},
params => {
type => 'hashref',
optional => 1,
default => {},
description => 'Caller-supplied defaults (lowest priority)',
schema => {
config_file => {
type => 'string',
optional => 1,
description => 'Primary config file path',
},
config_dirs => {
type => 'arrayref',
optional => 1,
description => 'Extra directories to search for config files',
},
logger => {
type => [qw(undef string arrayref hashref object)],
optional => 1,
description => 'Logger spec -- see CONFIGURATION/Logging',
},
carp_on_warn => {
type => 'boolean',
optional => 1,
default => 0,
description => 'Use Carp::carp for logger warnings',
},
croak_on_error => {
type => 'boolean',
optional => 1,
default => 1,
description => 'Use Carp::croak for logger errors',
},
},
},
}
=head4 Output
type => 'hashref',
description => 'Merged configuration hashref, ready to bless',
schema => {
logger => {
type => [qw(object string)],
description => 'Log::Abstraction instance or the string "NULL"',
},
_config_file => {
type => 'string',
optional => 1,
description => 'Path of the primary config file that was loaded',
},
_config_files => {
type => 'arrayref',
optional => 1,
description => 'All config file paths that were loaded, in load order',
},
}
=cut | |||||
| 546 | ||||||
| 547 | sub configure { | |||||
| 548 | 872 | 5429555 | my $class = $_[0]; | |||
| 549 | 872 | 1665 | my $params = $_[1] // {}; # caller's defaults; config file values override them | |||
| 550 | 872 | 716 | my $array_logger; # stash for an arrayref logger spec (Config::Abstraction rejects refs) | |||
| 551 | ||||||
| 552 | 872 | 2371 | croak(__PACKAGE__, ': configure: what class do you want to configure?') | |||
| 553 | if !defined($class) || $class eq ''; | |||||
| 554 | ||||||
| 555 | # SECURITY: validate $class is a syntactically valid Perl package name before | |||||
| 556 | # it propagates into env_prefix, croak messages, and %_chain_cache keys. | |||||
| 557 | # Exploit mechanism: a class name containing \n, ;, or shell metacharacters | |||||
| 558 | # can poison log lines, carp output, and env-variable lookups if not rejected here. | |||||
| 559 | # Under Perl taint mode (-T) an unvalidated external value would also fail the | |||||
| 560 | # taint check inside Config::Abstraction when used as an env_prefix substring. | |||||
| 561 | 836 | 3297 | croak(__PACKAGE__, ': configure: invalid class name (must be a valid Perl package name): ', $class) | |||
| 562 | unless $class =~ /\A[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*\z/; | |||||
| 563 | ||||||
| 564 | # Config::Abstraction, Log::Abstraction, and Return::Set all use eval internally | |||||
| 565 | # Protect the caller's $@ from being clobbered by our internal eval blocks. | |||||
| 566 | 687 | 515 | local $@; | |||
| 567 | ||||||
| 568 | # Config::Abstraction treats unknown scalar values as config file paths and will | |||||
| 569 | # attempt to read them, corrupting coderefs and object references. | |||||
| 570 | # Stash them here and restore after merging so callers never need this pattern. | |||||
| 571 | 687 | 537 | my %stashed_values; | |||
| 572 | 687 | 1056 | foreach my $key (keys %$params) { | |||
| 573 | 1247 | 1111 | next if $key eq 'logger'; # logger has its own path through _build_logger | |||
| 574 | 1178 | 809 | my $value = $params->{$key}; | |||
| 575 | 1178 | 1932 | if(ref($value) eq 'CODE' || blessed($value)) { | |||
| 576 | 57 | 88 | $stashed_values{$key} = delete $params->{$key}; | |||
| 577 | } | |||||
| 578 | } | |||||
| 579 | ||||||
| 580 | 675 | 1068 | if(exists($params->{'logger'}) && ref($params->{'logger'}) eq 'ARRAY') { | |||
| 581 | 20 | 22 | $array_logger = delete $params->{'logger'}; | |||
| 582 | } | |||||
| 583 | ||||||
| 584 | 675 | 553 | my $original_class = $class; | |||
| 585 | 675 | 1157 | $class =~ s/::/__/g; | |||
| 586 | ||||||
| 587 | 675 | 673 | my $config_file = $params->{'config_file'}; | |||
| 588 | 675 | 727 | my $config_dirs = $params->{'config_dirs'}; | |||
| 589 | ||||||
| 590 | # SECURITY (S1 â path traversal): reject config_file paths containing ".." segments | |||||
| 591 | # before they reach Config::Abstraction. The pen-test suite confirmed C::A parses | |||||
| 592 | # /etc/passwd as a colon-delimited conf file, injecting every user account as a | |||||
| 593 | # config key. The ".." guard blocks the traversal vector; direct absolute paths to | |||||
| 594 | # system files remain the caller's responsibility (document: config_file must be | |||||
| 595 | # developer-controlled, never raw user input). | |||||
| 596 | 675 | 1324 | if(defined($config_file) && $config_file =~ $RE_PATH_TRAVERSAL) { | |||
| 597 | 44 | 309 | croak("$class: config_file contains path traversal sequences: $config_file"); | |||
| 598 | } | |||||
| 599 | ||||||
| 600 | # _get_inheritance_chain returns [UNIVERSAL, ..., Base, Child] (base-first). | |||||
| 601 | # Reversing it below gives child-first for the discovery loop; the sort | |||||
| 602 | # that follows re-establishes base-first order for actual loading. | |||||
| 603 | 631 | 2017 | my @inheritance_chain = _get_inheritance_chain($original_class); | |||
| 604 | ||||||
| 605 | 631 | 595 | my @config_files_to_load = (); | |||
| 606 | 631 | 521 | my %tracked_files = (); | |||
| 607 | ||||||
| 608 | 631 | 658 | if($config_file) { | |||
| 609 | # Fail early so the error message carries the OS errno string while $! | |||||
| 610 | # is still fresh from the -r test, giving a locale-correct message. | |||||
| 611 | 255 | 868 | if(!$config_dirs && !-r $config_file) { | |||
| 612 | 18 | 306 | croak("$class: ", $config_file, ": $!"); | |||
| 613 | } | |||||
| 614 | ||||||
| 615 | 237 | 255 | foreach my $ancestor_class (reverse @inheritance_chain) { | |||
| 616 | 494 | 4497 | my $ancestor_config_file = _find_class_config_file( | |||
| 617 | $ancestor_class, | |||||
| 618 | $config_file, | |||||
| 619 | $config_dirs | |||||
| 620 | ); | |||||
| 621 | ||||||
| 622 | # Primary file is added separately at the end (highest priority) | |||||
| 623 | 494 | 639 | next if $ancestor_config_file && $ancestor_config_file eq $config_file; | |||
| 624 | ||||||
| 625 | 437 | 749 | if($ancestor_config_file && -r $ancestor_config_file && !$tracked_files{$ancestor_config_file}) { | |||
| 626 | 58 | 372 | push @config_files_to_load, { file => $ancestor_config_file, class => $ancestor_class }; | |||
| 627 | 58 | 66 | $tracked_files{$ancestor_config_file} = 1; | |||
| 628 | 58 | 307 | $_config_file_stats{$ancestor_config_file} = stat($ancestor_config_file) | |||
| 629 | if -f $ancestor_config_file; | |||||
| 630 | } | |||||
| 631 | } | |||||
| 632 | ||||||
| 633 | # Premise: $config_file is true (we are inside if($config_file) above). | |||||
| 634 | 237 | 1613 | if(!$tracked_files{$config_file} && -r $config_file) { | |||
| 635 | 97 | 188 | push @config_files_to_load, { file => $config_file, class => $original_class }; | |||
| 636 | 97 | 122 | $tracked_files{$config_file} = 1; | |||
| 637 | 97 | 494 | $_config_file_stats{$config_file} = stat($config_file) | |||
| 638 | if -f $config_file; | |||||
| 639 | } | |||||
| 640 | ||||||
| 641 | 237 | 7309 | if(!scalar(@config_files_to_load)) { | |||
| 642 | 107 107 | 61 95 | foreach my $dir (@{$config_dirs}) { | |||
| 643 | 145 | 394 | my $candidate = File::Spec->catfile($dir, $config_file); | |||
| 644 | 145 | 781 | if(-r $candidate) { | |||
| 645 | 89 | 137 | push @config_files_to_load, { file => $candidate, class => $original_class }; | |||
| 646 | 89 | 81 | last; # stop at first readable hit; later dirs are lower priority | |||
| 647 | } | |||||
| 648 | } | |||||
| 649 | } | |||||
| 650 | } | |||||
| 651 | ||||||
| 652 | 613 | 1479 | if(@config_files_to_load) { | |||
| 653 | # Sort so that base-class files are loaded before child files. | |||||
| 654 | # %class_order is keyed on the chain (UNIVERSAL=0, ..., Child=N). | |||||
| 655 | 219 | 172 | my %class_order; | |||
| 656 | 219 | 417 | for my $i (0 .. $#inheritance_chain) { | |||
| 657 | 458 | 584 | $class_order{ $inheritance_chain[$i] } = $i; | |||
| 658 | } | |||||
| 659 | @config_files_to_load = sort { | |||||
| 660 | 219 31 | 280 66 | ($class_order{ $a->{class} } // 999) <=> ($class_order{ $b->{class} } // 999) | |||
| 661 | } @config_files_to_load; | |||||
| 662 | ||||||
| 663 | 219 | 348 | my $merged_params = { %$params }; | |||
| 664 | ||||||
| 665 | 219 | 234 | foreach my $config_info (@config_files_to_load) { | |||
| 666 | 244 | 457 | my $cfg_file = $config_info->{file}; | |||
| 667 | 244 | 189 | my $cfg_class = $config_info->{class}; | |||
| 668 | 244 | 198 | my $section_name = $cfg_class; | |||
| 669 | 244 | 370 | $section_name =~ s/::/__/g; | |||
| 670 | ||||||
| 671 | # Load only the specific file; do not re-pass config_dirs to avoid | |||||
| 672 | # re-scanning directories and picking up the wrong file for this class. | |||||
| 673 | 244 | 856 | my $config = Config::Abstraction->new( | |||
| 674 | config_file => $cfg_file, | |||||
| 675 | env_prefix => "${section_name}__" | |||||
| 676 | ); | |||||
| 677 | ||||||
| 678 | 244 | 1380230 | if($config) { | |||
| 679 | 233 | 446 | my $this_config = $config->merge_defaults( | |||
| 680 | defaults => {}, | |||||
| 681 | section => $section_name, | |||||
| 682 | merge => 1, | |||||
| 683 | deep => 1 | |||||
| 684 | ); | |||||
| 685 | 233 | 19111 | $merged_params = _deep_merge($merged_params, $this_config); | |||
| 686 | } elsif($@) { | |||||
| 687 | 7 | 169 | carp("Warning: Can't load configuration from $cfg_file: $@"); | |||
| 688 | } | |||||
| 689 | } | |||||
| 690 | ||||||
| 691 | 219 | 3534 | $params = $merged_params; | |||
| 692 | } elsif(my $config = Config::Abstraction->new(env_prefix => "${class}__")) { | |||||
| 693 | # No config file: honour environment variables across the full ancestor chain. | |||||
| 694 | 14 | 13142 | my $merged_config = {}; | |||
| 695 | ||||||
| 696 | # Iterate base-first so that each more-specific class overrides the more | |||||
| 697 | # general one: UNIVERSAL â GrandParent â Parent â Child. | |||||
| 698 | 14 | 17 | foreach my $ancestor_class (@inheritance_chain) { | |||
| 699 | 29 | 30 | my $section_name = $ancestor_class; | |||
| 700 | 29 | 40 | $section_name =~ s/::/__/g; | |||
| 701 | ||||||
| 702 | 29 | 57 | my $ancestor_env_config = Config::Abstraction->new(env_prefix => "${section_name}__"); | |||
| 703 | 29 | 16307 | if($ancestor_env_config) { | |||
| 704 | 15 | 33 | my $ancestor_config = $ancestor_env_config->merge_defaults( | |||
| 705 | defaults => {}, | |||||
| 706 | section => $section_name, | |||||
| 707 | merge => 1, | |||||
| 708 | deep => 1 | |||||
| 709 | ); | |||||
| 710 | 15 | 975 | $merged_config = _deep_merge($merged_config, $ancestor_config); | |||
| 711 | } | |||||
| 712 | } | |||||
| 713 | ||||||
| 714 | 14 | 137 | $params = $config->merge_defaults( | |||
| 715 | defaults => $params, | |||||
| 716 | section => $class, | |||||
| 717 | merge => 1, | |||||
| 718 | deep => 1 | |||||
| 719 | ); | |||||
| 720 | ||||||
| 721 | 14 | 751 | $params = _deep_merge($merged_config, $params); | |||
| 722 | ||||||
| 723 | # SECURITY (S1 extension â config_path traversal + taint): | |||||
| 724 | # config_path arrives from Config::Abstraction, which reads %ENV. Under | |||||
| 725 | # taint mode (-T) the value is tainted; any syscall with a tainted argument | |||||
| 726 | # is fatal. Apply the same $RE_PATH_TRAVERSAL guard as config_file (line 491) | |||||
| 727 | # before any filesystem probe, so the two guards stay in sync. | |||||
| 728 | # Exploit: ClassName__config_path=../../etc/shadow causes the hot-reload watcher | |||||
| 729 | # to stat() and track an arbitrary system file, leaking mtime changes via SIGUSR1. | |||||
| 730 | 14 | 25 | if($params->{config_path}) { | |||
| 731 | croak(__PACKAGE__, ': config_path contains path traversal sequences: ', | |||||
| 732 | $params->{config_path}) | |||||
| 733 | 2 | 4 | if $params->{config_path} =~ $RE_PATH_TRAVERSAL; | |||
| 734 | $_config_file_stats{ $params->{config_path} } = stat($params->{config_path}) | |||||
| 735 | 2 | 18 | if -f $params->{config_path}; | |||
| 736 | } | |||||
| 737 | } | |||||
| 738 | ||||||
| 739 | 613 | 264843 | my $croak_on_error = exists($params->{'croak_on_error'}) ? $params->{'croak_on_error'} : 1; | |||
| 740 | 613 | 612 | my $carp_on_warn = exists($params->{'carp_on_warn'}) ? $params->{'carp_on_warn'} : 0; | |||
| 741 | ||||||
| 742 | # User-supplied logger always wins over config-file logger. | |||||
| 743 | # $array_logger is defined when the caller passed an arrayref; it was deleted from | |||||
| 744 | # $params before config merging so the merge couldn't overwrite it. Config-file logger | |||||
| 745 | # (a hashref from YAML) is only used when the caller gave no explicit logger at all. | |||||
| 746 | 613 | 664 | my $logger_spec = defined($array_logger) ? $array_logger : $params->{'logger'}; | |||
| 747 | 613 | 740 | $params->{'logger'} = _build_logger($logger_spec, $carp_on_warn); | |||
| 748 | ||||||
| 749 | 613 | 1246970 | if(!exists($params->{_config_file})) { | |||
| 750 | 606 | 803 | $params->{_config_file} = $config_file if defined $config_file; | |||
| 751 | } | |||||
| 752 | 613 | 634 | if(!exists($params->{_config_files})) { | |||
| 753 | 607 242 | 647 415 | $params->{_config_files} = [ map { $_->{file} } @config_files_to_load ] | |||
| 754 | if @config_files_to_load; | |||||
| 755 | } | |||||
| 756 | ||||||
| 757 | # Re-attach stashed coderefs/objects via hash slice | |||||
| 758 | 613 37 | 630 40 | @{$params}{ keys %stashed_values } = values %stashed_values if %stashed_values; | |||
| 759 | ||||||
| 760 | 613 | 1224 | return Return::Set::set_return($params, { 'type' => 'hashref' }); | |||
| 761 | } | |||||
| 762 | ||||||
| 763 - 850 | =head2 instantiate(%params)
Configure and instantiate a third-party class without modifying the class itself.
C<instantiate> is a convenience wrapper: it calls C<configure>, passes the merged
hashref to C<$class-E<gt>new(...)>, and optionally registers the result for hot reload.
Use it when you need runtime configuration for a class whose source you cannot change.
=head3 Arguments
Takes a flat hash (not a hashref). Recognized keys:
=over 4
=item * C<class> (Required, string)
The fully-qualified class name to instantiate (e.g., C<'LWP::UserAgent'>). The class
must already be loaded and must have a C<new> method that accepts a hashref.
=item * All other keys
Passed through to C<configure()> as C<\%params>. See L</configure($class, \%params)>
for the full list.
=back
=head3 Returns
A blessed object of type C<$class>.
B<Note:> The returned object's hash will contain a C<class> key holding the class name.
This is intentional -- it is left in the hash as a debugging aid so you can always
see which class an object came from. Do not depend on its absence.
=head3 Side Effects
=over 4
=item * Calls C<configure($class, \%params)> -- see its side effects.
=item * Calls C<$class-E<gt>new(\%merged_params)>.
=item * If the config produced a C<_config_file>, calls C<register_object($class, $obj)>
so the object participates in hot reload.
=back
=head3 Error messages
Same as C<configure()>. In addition:
=over 4
=item * Any exception thrown by C<$class-E<gt>new(...)> propagates unchanged.
=back
=head3 Usage Example
use Object::Configure;
my $ua = Object::Configure::instantiate(
class => 'LWP::UserAgent',
config_file => 'lwp.yml',
config_dirs => ['/etc/myapp'],
timeout => 30,
);
=head3 API Specification
=head4 Input
schema => {
class => {
type => 'string',
required => 1,
description => 'Fully-qualified class name; must respond to new(hashref)',
},
# all other keys forwarded to configure()
}
=head4 Output
type => 'object',
description => 'Blessed instance of $class, with class key present in hash',
notes => 'class key intentionally left in hash as a debugging aid',
=cut | |||||
| 851 | ||||||
| 852 | sub instantiate | |||||
| 853 | { | |||||
| 854 | 33 | 136769 | my $params = Params::Get::get_params('class', @_); | |||
| 855 | ||||||
| 856 | # 'class' is read into $class then left in $params, | |||||
| 857 | # 'class' propagates into configure() as a spurious config key and | |||||
| 858 | # ends up in the blessed object's hash, allowing the caller to see | |||||
| 859 | # that has come from what, helping debugging | |||||
| 860 | 33 | 497 | my $class = $params->{'class'}; | |||
| 861 | 33 | 53 | $params = configure($class, $params); | |||
| 862 | ||||||
| 863 | 28 | 2220 | my $obj = $class->new($params); | |||
| 864 | ||||||
| 865 | 23 | 154 | register_object($class, $obj) if $params->{_config_file}; | |||
| 866 | ||||||
| 867 | 23 | 43 | return $obj; | |||
| 868 | } | |||||
| 869 | ||||||
| 870 - 973 | =head1 HOT RELOAD FEATURES
=head2 enable_hot_reload(%opts)
Fork a background watcher that sends SIGUSR1 to the parent whenever a tracked
configuration file changes on disk. Objects registered via C<register_object()>
then have their configuration reloaded automatically.
B<Unix only.> On Windows this function is a silent no-op (SIGUSR1 does not exist).
=head3 Arguments
Takes a flat hash. All keys are optional.
=over 4
=item * C<interval> (integer E<gt>= 1, default: 10)
Seconds between file-modification checks. Lower values detect changes faster
but use more CPU. Zero or negative values are silently replaced with the default.
=item * C<callback> (coderef, optional)
Called in the parent process after each successful config reload. Useful for
logging or flushing caches.
=back
=head3 Returns
The PID of the watcher child process (integer E<gt> 0), or C<undef>/empty if hot
reload was already active (idempotent: a second call returns immediately without
forking again).
=head3 Side Effects
=over 4
=item * Forks a child process.
=item * The child polls C<%_config_file_stats> and sends C<SIGUSR1> to the parent
on mtime change.
=item * Stores C<{pid =E<gt> $pid, callback =E<gt> $cb}> in C<%_config_watchers>.
=back
=head3 Error messages
=over 4
=item * C<Object::Configure: fork failed: OS-ERROR> -- C<fork()> returned C<undef>.
Check system resource limits (C<ulimit -u>).
=back
=head3 Usage Example
Object::Configure::enable_hot_reload(
interval => 5,
callback => sub { warn "Config reloaded at " . localtime . "\n" },
);
while (1) { sleep 1 } # watcher runs in the background
=head3 API Specification
=head4 Input
schema => {
interval => {
type => 'integer',
optional => 1,
default => 10,
minimum => 1,
description => 'Poll interval in seconds',
},
callback => {
type => 'coderef',
optional => 1,
description => 'Called in parent after each reload',
},
}
=head4 Output
type => [qw(integer undef)],
description => 'PID of watcher child; undef/empty if already active',
condition => 'value > 0 when defined',
enable_hot_reload : Interval x Callback -> PID | empty
Pre:
interval >= 1 (enforced: negative/zero replaced with DEFAULT_INTERVAL)
_config_watchers = {}
Post:
_config_watchers.pid = result
_config_watchers.callback = callback
(forall t: t mod interval = 0 =>
(exists f in _config_file_stats: mtime(f) changed =>
send_signal(SIGUSR1, parent_pid)))
=cut | |||||
| 974 | ||||||
| 975 | sub enable_hot_reload { | |||||
| 976 | 65 | 110068 | my %params = @_; | |||
| 977 | ||||||
| 978 | # SECURITY (S4): use defined-or (//) not truth-or (||) so that interval=>0 | |||||
| 979 | # is not silently replaced. Then guard: a zero or negative interval would make | |||||
| 980 | # the child busy-loop, consuming 100% CPU (internal DoS vector). | |||||
| 981 | 65 | 200 | my $interval = $params{interval} // $DEFAULT_INTERVAL; | |||
| 982 | 65 | 261 | $interval = $DEFAULT_INTERVAL unless $interval > 0; | |||
| 983 | 65 | 126 | my $callback = $params{callback}; | |||
| 984 | ||||||
| 985 | 65 | 150 | return if %_config_watchers; # already watching; avoid double-fork | |||
| 986 | ||||||
| 987 | 59 | 62289 | if(my $pid = fork()) { | |||
| 988 | 43 | 1837 | $_config_watchers{pid} = $pid; | |||
| 989 | 43 | 918 | $_config_watchers{callback} = $callback; | |||
| 990 | 43 | 2589 | return $pid; | |||
| 991 | } elsif(defined $pid) { | |||||
| 992 | # Child: run forever, signal parent on change | |||||
| 993 | 16 | 947 | _run_config_watcher($interval, $callback); | |||
| 994 | 0 | 0 | exit 0; | |||
| 995 | } else { | |||||
| 996 | 0 | 0 | croak("Failed to fork config watcher: $!"); | |||
| 997 | } | |||||
| 998 | } | |||||
| 999 | ||||||
| 1000 - 1042 | =head2 disable_hot_reload()
Stop the background watcher and clear hot-reload state.
Safe to call when hot reload is not active (no-op). After this call,
configuration files are no longer monitored and C<%_config_watchers> is empty.
=head3 Arguments
None.
=head3 Returns
Nothing (void).
=head3 Side Effects
=over 4
=item * Sends SIGTERM to the watcher child.
=item * Polls for up to C<$KILL_TIMEOUT> seconds (default: 5); escalates to SIGKILL
if the child has not exited by then.
=item * Calls C<waitpid> to reap the child.
=item * Clears C<%_config_watchers>.
=back
B<Blocking>: this function may take up to five seconds if the watcher ignores SIGTERM.
=head3 API Specification
=head4 Input
schema => {} # no arguments
=head4 Output
type => 'void'
=cut | |||||
| 1043 | ||||||
| 1044 | sub disable_hot_reload { | |||||
| 1045 | ## MUTANT_SKIP_BEGIN | |||||
| 1046 | 106 | 80409 | if(my $pid = $_config_watchers{pid}) { | |||
| 1047 | # SECURITY (S3 â PID safety): | |||||
| 1048 | # $pid > 1 excludes PID 0 (sends SIGTERM to whole process group â DoS) | |||||
| 1049 | # and PID 1 (init â catastrophic as root). | |||||
| 1050 | # $pid != $$ prevents self-signaling if %_config_watchers is corrupted. | |||||
| 1051 | # Exploit mechanism: if global state is poisoned with pid=0 or pid=1, | |||||
| 1052 | # kill('TERM', 0) signals every process in the group; kill('TERM', 1) kills | |||||
| 1053 | # init as root. Both are now rejected at the comparison level. | |||||
| 1054 | 50 | 1688 | if($pid =~ /\A[0-9]+\z/ && $pid > 1 && $pid != $$) { | |||
| 1055 | 43 | 526 | kill('TERM', $pid); | |||
| 1056 | ||||||
| 1057 | # Poll up to KILL_TIMEOUT seconds; escalate to SIGKILL if SIGTERM is ignored. | |||||
| 1058 | # SIGKILL cannot be caught or deferred so the subsequent waitpid is always safe. | |||||
| 1059 | 43 | 1227 | my $deadline = time() + $KILL_TIMEOUT; | |||
| 1060 | 43 | 562 | my $kid; | |||
| 1061 | 43 | 93 | do { | |||
| 1062 | 177 | 13425485 | $kid = waitpid($pid, WNOHANG); | |||
| 1063 | 177 | 1412 | if($kid == 0 && time() < $deadline) { | |||
| 1064 | 134 | 974 | select undef, undef, undef, $POLL_SLEEP; | |||
| 1065 | } | |||||
| 1066 | } while($kid == 0 && time() < $deadline); | |||||
| 1067 | ||||||
| 1068 | 43 | 278 | if($kid == 0) { | |||
| 1069 | 0 | 0 | kill('KILL', $pid); | |||
| 1070 | 0 | 0 | waitpid($pid, 0); | |||
| 1071 | } | |||||
| 1072 | } | |||||
| 1073 | 50 | 580 | %_config_watchers = (); | |||
| 1074 | } | |||||
| 1075 | ## MUTANT_SKIP_END | |||||
| 1076 | } | |||||
| 1077 | ||||||
| 1078 - 1122 | =head2 reload_config()
Immediately reload configuration from disk for every registered object.
Normally called automatically by the SIGUSR1 handler. You may call it manually
to force a reload (e.g., in tests or on a custom signal).
=head3 Arguments
None.
=head3 Returns
An integer E<gt>= 0: the count of objects whose configuration was successfully
reloaded.
=head3 Side Effects
=over 4
=item * Reads config files from disk for each registered object.
=item * Updates non-private keys (those not starting with C<_>) in-place on
each live object.
=item * Prunes dead weak references from C<%_object_registry>.
=item * Emits a C<carp> warning (not a croak) if reload fails for any individual
object; other objects are still processed.
=back
=head3 API Specification
=head4 Input
schema => {} # no arguments
=head4 Output
type => 'integer',
description => 'Count of objects successfully reloaded',
condition => 'value >= 0',
=cut | |||||
| 1123 | ||||||
| 1124 | sub reload_config { | |||||
| 1125 | 80 | 606806 | my $reloaded_count = 0; | |||
| 1126 | ||||||
| 1127 | 80 | 167 | foreach my $class_key (keys %_object_registry) { | |||
| 1128 | 78 | 101 | my $objects = $_object_registry{$class_key}; | |||
| 1129 | ||||||
| 1130 | 78 101 | 113 180 | @$objects = grep { defined $$_ } @$objects; # prune garbage-collected weak refs (check referent, not the ref-to-scalar itself) | |||
| 1131 | ||||||
| 1132 | 78 | 82 | foreach my $obj_ref (@$objects) { | |||
| 1133 | 70 | 110 | if(my $obj = $$obj_ref) { | |||
| 1134 | # Protect the caller's $@ from being clobbered by our internal eval blocks. | |||||
| 1135 | 70 | 57 | local $@; | |||
| 1136 | 70 | 62 | eval { | |||
| 1137 | 70 | 110 | _reload_object_config($obj); | |||
| 1138 | 65 | 433 | $reloaded_count++; | |||
| 1139 | }; | |||||
| 1140 | 70 | 134 | if($@) { | |||
| 1141 | 5 | 63 | carp("Failed to reload config for object: $@"); | |||
| 1142 | } | |||||
| 1143 | } | |||||
| 1144 | } | |||||
| 1145 | ||||||
| 1146 | 78 | 866 | delete $_object_registry{$class_key} unless @$objects; | |||
| 1147 | } | |||||
| 1148 | ||||||
| 1149 | 80 | 105 | return $reloaded_count; | |||
| 1150 | } | |||||
| 1151 | ||||||
| 1152 - 1242 | =head2 register_object($class, $obj)
Register a blessed object so it receives configuration updates when files change.
B<Push semantics>: each call I<appends> a new entry to the registry for C<$class>.
It does not replace a previous entry. Multiple objects of the same class are all
tracked and all reloaded.
=head3 Arguments
=over 4
=item * C<$class> (Required, string)
The class name used to organise the registry. Typically C<ref($self)> or the
calling package name.
=item * C<$obj> (Required, blessed reference)
The object to register. B<Must be a blessed reference.> Passing an unblessed
hashref or any other unblessed value causes an immediate C<croak>.
=back
=head3 Returns
Nothing (void).
=head3 Side Effects
=over 4
=item * Pushes a weak reference to C<$obj> onto C<$_object_registry{$class}>.
=item * On the first call ever (for any class): saves the current C<$SIG{USR1}>
and installs Object::Configure's handler. On Unix, the handler calls
C<reload_config()> then chains to the prior handler. On Windows, signal
installation is skipped but C<$_original_usr1_handler> is still set.
=back
=head3 Error messages
=over 4
=item * C<Object::Configure::register_object: Usage ($class, $obj)> --
either C<$class> or C<$obj> was C<undef>.
=item * C<Object::Configure::register_object: $obj must be a blessed reference> --
C<$obj> was defined but not blessed. This guard prevents DoS via registry flooding
(reloading thousands of unblessed entries on every SIGUSR1).
=back
=head3 Usage Example
package My::Module;
use Object::Configure;
sub new {
my ($class, %args) = @_;
my $params = Object::Configure::configure($class, \%args);
my $self = bless $params, $class;
Object::Configure::register_object($class, $self)
if $self->{_config_file};
return $self;
}
=head3 API Specification
=head4 Input
schema => {
class => {
type => 'string',
required => 1,
description => 'Class name for registry key',
},
obj => {
type => 'object',
required => 1,
description => 'Blessed object to register; unblessed refs are rejected',
blessed => 1,
},
}
=head4 Output
type => 'void'
=cut | |||||
| 1243 | ||||||
| 1244 | sub register_object | |||||
| 1245 | { | |||||
| 1246 | 171 | 306017 | my ($class, $obj) = @_; | |||
| 1247 | ||||||
| 1248 | 171 | 596 | croak(__PACKAGE__, '::register_object: Usage ($class, $obj)') | |||
| 1249 | unless defined($class) && defined($obj); | |||||
| 1250 | ||||||
| 1251 | # SECURITY: enforce the API contract (POD: "$obj must be a blessed reference"). | |||||
| 1252 | # Accepting unblessed refs silently would allow DoS via registry flooding: an | |||||
| 1253 | # adversary or buggy caller can push thousands of unblessed entries; reload_config() | |||||
| 1254 | # iterates every entry on every SIGUSR1, degrading throughput proportionally. | |||||
| 1255 | 142 | 314 | croak(__PACKAGE__, '::register_object: $obj must be a blessed reference') | |||
| 1256 | unless blessed($obj); | |||||
| 1257 | ||||||
| 1258 | 136 | 148 | my $obj_ref = \$obj; | |||
| 1259 | 136 | 212 | weaken($$obj_ref); | |||
| 1260 | 136 136 | 112 191 | push @{ $_object_registry{$class} }, $obj_ref; | |||
| 1261 | ||||||
| 1262 | # Install SIGUSR1 handler exactly once. We save the previous handler so | |||||
| 1263 | # we can chain to it (another module may have installed one) and restore it | |||||
| 1264 | # on shutdown. On Windows SIGUSR1 does not exist so we skip the signal work | |||||
| 1265 | # but still save $_original_usr1_handler so restore_signal_handlers is safe. | |||||
| 1266 | 136 | 192 | if(!defined $_original_usr1_handler) { | |||
| 1267 | 60 | 217 | $_original_usr1_handler = $SIG{USR1} || $SIG_DEFAULT; | |||
| 1268 | ||||||
| 1269 | 60 | 240 | return if $^O eq $OS_WINDOWS; | |||
| 1270 | ||||||
| 1271 | $SIG{USR1} = sub { | |||||
| 1272 | 15 | 1014785 | reload_config(); | |||
| 1273 | 15 | 24 | $_config_watchers{callback}->() if $_config_watchers{callback}; | |||
| 1274 | ||||||
| 1275 | 15 | 29 | if(ref($_original_usr1_handler) eq 'CODE') { | |||
| 1276 | 8 | 14 | $_original_usr1_handler->(); | |||
| 1277 | } elsif($_original_usr1_handler eq $SIG_DEFAULT | |||||
| 1278 | || $_original_usr1_handler eq $SIG_IGNORE) { | |||||
| 1279 | # DEFAULT for USR1 is typically a no-op; IGNORE means discard | |||||
| 1280 | } else { | |||||
| 1281 | 2 | 20 | carp("Object::Configure: Cannot chain to non-code USR1 handler: $_original_usr1_handler"); | |||
| 1282 | } | |||||
| 1283 | 60 | 561 | }; | |||
| 1284 | } | |||||
| 1285 | ||||||
| 1286 | 136 | 200 | return; | |||
| 1287 | } | |||||
| 1288 | ||||||
| 1289 - 1326 | =head2 restore_signal_handlers()
Restore C<$SIG{USR1}> to the handler that was in place before
C<register_object()> installed the hot-reload handler, and clear
C<$_original_usr1_handler>.
Safe to call even when Object::Configure never installed a handler (no-op).
On Windows this function has no effect (SIGUSR1 does not exist there).
=head3 Arguments
None.
=head3 Returns
Nothing (void).
=head3 Side Effects
=over 4
=item * Sets C<$SIG{USR1}> back to its saved value (Unix only).
=item * Sets C<$_original_usr1_handler> to C<undef>.
=back
=head3 API Specification
=head4 Input
schema => {} # no arguments
=head4 Output
type => 'void'
=cut | |||||
| 1327 | ||||||
| 1328 | sub restore_signal_handlers | |||||
| 1329 | { | |||||
| 1330 | 92 | 392456 | if(defined $_original_usr1_handler) { | |||
| 1331 | 48 | 193 | $SIG{USR1} = $_original_usr1_handler unless $^O eq $OS_WINDOWS; | |||
| 1332 | 48 | 494 | $_original_usr1_handler = undef; | |||
| 1333 | } | |||||
| 1334 | ||||||
| 1335 | 92 | 427 | return; | |||
| 1336 | } | |||||
| 1337 | ||||||
| 1338 - 1398 | =head2 get_signal_handler_info()
Return a snapshot of the current signal-handler and hot-reload state.
This is a debugging aid; normal application code does not need to call it.
=head3 Arguments
None.
=head3 Returns
A hashref with these keys:
=over 4
=item * C<original_usr1> -- the C<$SIG{USR1}> value that existed before
Object::Configure installed its handler, or C<undef> if no handler was saved yet.
=item * C<current_usr1> -- the currently installed C<$SIG{USR1}> handler (coderef,
C<'DEFAULT'>, C<'IGNORE'>, or C<undef>).
=item * C<hot_reload_active> -- C<1> if C<$_original_usr1_handler> is defined,
C<''> otherwise.
=item * C<watcher_pid> -- the PID of the background watcher child, or C<undef>
if C<enable_hot_reload()> has not been called (or the watcher has been stopped).
=back
=head3 Usage Example
use Object::Configure;
use Data::Dumper;
Object::Configure::enable_hot_reload();
print Dumper(Object::Configure::get_signal_handler_info());
# {
# original_usr1 => 'DEFAULT',
# current_usr1 => sub { ... },
# hot_reload_active => 1,
# watcher_pid => 12345,
# }
=head3 API Specification
=head4 Input
schema => {} # no arguments
=head4 Output
type => 'hashref',
description => 'Snapshot of signal-handler and watcher state',
schema => {
original_usr1 => { type => [qw(coderef string undef)] },
current_usr1 => { type => [qw(coderef string undef)] },
hot_reload_active => { type => 'boolean' },
watcher_pid => { type => [qw(integer undef)] },
}
=cut | |||||
| 1399 | ||||||
| 1400 | sub get_signal_handler_info { | |||||
| 1401 | return { | |||||
| 1402 | original_usr1 => $_original_usr1_handler, | |||||
| 1403 | current_usr1 => $SIG{USR1}, | |||||
| 1404 | hot_reload_active => defined $_original_usr1_handler, | |||||
| 1405 | watcher_pid => $_config_watchers{pid}, | |||||
| 1406 | 18 | 14211 | }; | |||
| 1407 | } | |||||
| 1408 | ||||||
| 1409 | # ---------------------------------------------------------------------------- | |||||
| 1410 | # Private helpers | |||||
| 1411 | # All routines below are implementation details; callers must not rely on them. | |||||
| 1412 | # ---------------------------------------------------------------------------- | |||||
| 1413 | ||||||
| 1414 | # Purpose: Consolidate all logger-creation paths into one place. | |||||
| 1415 | # Called from configure() and _reconfigure_logger() to eliminate | |||||
| 1416 | # the duplication that existed between the two. | |||||
| 1417 | # Entry: $spec may be: undef (want default), the string 'NULL' (no logging), | |||||
| 1418 | # an ARRAY ref (log-capture array), a HASH ref (options for Log::Abstraction), | |||||
| 1419 | # a pre-built Log::Abstraction instance (pass through), or any other | |||||
| 1420 | # scalar (treated as a logger name / file path). | |||||
| 1421 | # $carp_on_warn is a boolean controlling Carp::carp integration. | |||||
| 1422 | # Exit: Returns a Log::Abstraction instance, or the string 'NULL'. | |||||
| 1423 | # Side: May allocate a new Log::Abstraction object. | |||||
| 1424 | sub _build_logger { | |||||
| 1425 | 663 | 46555 | my ($spec, $carp_on_warn) = @_; | |||
| 1426 | 663 | 650 | $carp_on_warn //= 0; | |||
| 1427 | ||||||
| 1428 | 663 | 1504 | return Log::Abstraction->new(carp_on_warn => $carp_on_warn) | |||
| 1429 | unless defined $spec; | |||||
| 1430 | ||||||
| 1431 | 124 | 224 | return $LOGGER_NULL | |||
| 1432 | if !ref($spec) && $spec eq $LOGGER_NULL; | |||||
| 1433 | ||||||
| 1434 | 103 | 262 | return $spec | |||
| 1435 | if blessed($spec) && $spec->isa('Log::Abstraction'); | |||||
| 1436 | ||||||
| 1437 | 88 | 116 | if(ref($spec) eq 'ARRAY') { | |||
| 1438 | 24 | 49 | return Log::Abstraction->new(array => $spec, carp_on_warn => $carp_on_warn); | |||
| 1439 | } | |||||
| 1440 | ||||||
| 1441 | 64 | 77 | if(ref($spec) eq 'HASH') { | |||
| 1442 | 43 | 133 | return Log::Abstraction->new({ carp_on_warn => $carp_on_warn, %$spec }); | |||
| 1443 | } | |||||
| 1444 | ||||||
| 1445 | # Scalar: a logger name, file path, or other string identifier passed to L::A | |||||
| 1446 | 21 | 78 | return Log::Abstraction->new({ carp_on_warn => $carp_on_warn, logger => $spec }); | |||
| 1447 | } | |||||
| 1448 | ||||||
| 1449 | # Purpose: Build the ancestor chain needed for config-file discovery and env merging. | |||||
| 1450 | # Uses the class's own MRO (DFS or C3) via mro::get_linear_isa, which is | |||||
| 1451 | # more correct than a hardcoded DFS walk and handles diamond inheritance. | |||||
| 1452 | # UNIVERSAL is added explicitly because mro::get_linear_isa does not include | |||||
| 1453 | # it unless it appears in @ISA, yet Object::Configure supports universal.yml. | |||||
| 1454 | # Entry: $class is a fully-qualified class name that has already been loaded. | |||||
| 1455 | # Exit: Returns a list in base-first order: (UNIVERSAL, ..., GrandParent, Parent, Class). | |||||
| 1456 | # Result is memoized in %_chain_cache; mro::get_linear_isa is not re-invoked | |||||
| 1457 | # for classes already seen in this process. The cache is valid for the process | |||||
| 1458 | # lifetime because @ISA is stable after module load in normal Perl programs. | |||||
| 1459 | # Algorithmic cost: O(N) first call; O(1) amortised on subsequent calls. | |||||
| 1460 | sub _get_inheritance_chain { | |||||
| 1461 | 661 | 122076 | my ($class) = @_; | |||
| 1462 | ||||||
| 1463 | # Cache hit: return a copy of the stored list (caller may modify the returned list). | |||||
| 1464 | 661 245 | 864 461 | return @{ $_chain_cache{$class} } if $_chain_cache{$class}; | |||
| 1465 | ||||||
| 1466 | 416 416 | 274 1243 | my @mro = @{ mro::get_linear_isa($class) }; | |||
| 1467 | ||||||
| 1468 | # mro::get_linear_isa returns child-first; reverse to get base-first. | |||||
| 1469 | # UNIVERSAL is implicit in Perl's type system but not always in the MRO list, | |||||
| 1470 | # so append it when absent to ensure universal.yml is picked up. | |||||
| 1471 | # List::Util::any is XS and short-circuits on first match -- faster than grep. | |||||
| 1472 | 416 465 | 1306 741 | push @mro, 'UNIVERSAL' unless any { $_ eq 'UNIVERSAL' } @mro; | |||
| 1473 | ||||||
| 1474 | 416 | 901 | my @chain = reverse @mro; | |||
| 1475 | 416 | 629 | $_chain_cache{$class} = \@chain; # store arrayref; return list copy below | |||
| 1476 | 416 | 640 | return @chain; | |||
| 1477 | } | |||||
| 1478 | ||||||
| 1479 | # Purpose: Find a config file for a specific ancestor class using the same | |||||
| 1480 | # naming convention as the primary config file (directory + extension). | |||||
| 1481 | # Entry: $class is a fully-qualified class name. | |||||
| 1482 | # $base_config_file is the primary config file path (provides dir and ext). | |||||
| 1483 | # $config_dirs is an optional arrayref of additional search directories. | |||||
| 1484 | # Does NOT modify elements of $config_dirs (trailing-slash removal uses a copy). | |||||
| 1485 | # Exit: Returns a readable file path, or undef if nothing found. | |||||
| 1486 | # Result (including undef for "not found") is memoized in %_find_cache, keyed | |||||
| 1487 | # by (class, base_config_file, config_dirs-elements) joined with NUL bytes. | |||||
| 1488 | # Subsequent calls with identical arguments skip all filesystem probes. | |||||
| 1489 | # Algorithmic cost: O(extensions) syscalls first call; O(1) amortised. | |||||
| 1490 | sub _find_class_config_file { | |||||
| 1491 | 527 | 51194 | my ($class, $base_config_file, $config_dirs) = @_; | |||
| 1492 | ||||||
| 1493 | # Build a NUL-separated cache key. NUL cannot appear in Linux file paths (the | |||||
| 1494 | # pen-test suite confirms this: Perl's -r warns and returns undef for NUL paths). | |||||
| 1495 | 527 | 798 | my $cache_key = join("\0", $class, $base_config_file, | |||
| 1496 | $config_dirs ? @$config_dirs : ()); | |||||
| 1497 | 527 | 688 | return $_find_cache{$cache_key} if exists $_find_cache{$cache_key}; | |||
| 1498 | ||||||
| 1499 | 444 | 422 | my $class_file = lc($class); | |||
| 1500 | 444 | 573 | $class_file =~ s/::/-/g; | |||
| 1501 | ||||||
| 1502 | 444 | 2275 | my ($base_vol, $base_dir_part, $base_name_ext) = File::Spec->splitpath($base_config_file); | |||
| 1503 | 444 | 1341 | my (undef, $base_ext) = $base_name_ext =~ /^(.*?)(\.[^.]+)?$/; | |||
| 1504 | 444 | 501 | $base_ext //= ''; | |||
| 1505 | 444 | 977 | my $base_dir = File::Spec->catpath($base_vol, $base_dir_part, ''); | |||
| 1506 | ||||||
| 1507 | # Single-exit-point via labeled block so the cache write happens unconditionally. | |||||
| 1508 | 444 | 326 | my $found; | |||
| 1509 | SEARCH: { | |||||
| 1510 | # Dedup: when $base_ext is already .yml/.conf/etc the first candidate would | |||||
| 1511 | # be a duplicate of a later one, causing a redundant filesystem probe. | |||||
| 1512 | 444 444 | 265 303 | my %_seen_pat; | |||
| 1513 | 444 2220 | 4129 2454 | foreach my $pattern (grep { !$_seen_pat{$_}++ } ( | |||
| 1514 | File::Spec->catfile($base_dir, "${class_file}${base_ext}"), | |||||
| 1515 | File::Spec->catfile($base_dir, "${class_file}.conf"), | |||||
| 1516 | File::Spec->catfile($base_dir, "${class_file}.yml"), | |||||
| 1517 | File::Spec->catfile($base_dir, "${class_file}.yaml"), | |||||
| 1518 | File::Spec->catfile($base_dir, "${class_file}.json"), | |||||
| 1519 | )) { | |||||
| 1520 | 1630 | 6530 | if(-r $pattern && -f $pattern) { | |||
| 1521 | 54 | 54 | $found = $pattern; | |||
| 1522 | 54 | 77 | last SEARCH; | |||
| 1523 | } | |||||
| 1524 | } | |||||
| 1525 | ||||||
| 1526 | 390 | 1191 | if($config_dirs && ref($config_dirs) eq 'ARRAY') { | |||
| 1527 | 301 | 310 | foreach my $dir (@$config_dirs) { | |||
| 1528 | # Use a copy so the caller's arrayref element is never mutated. | |||||
| 1529 | 382 | 348 | (my $clean_dir = $dir) =~ s{/$}{}; | |||
| 1530 | 382 | 195 | my %_seen_dir_pat; | |||
| 1531 | 382 1910 | 622 1861 | foreach my $pattern (grep { !$_seen_dir_pat{$_}++ } ( | |||
| 1532 | "${clean_dir}/${class_file}${base_ext}", | |||||
| 1533 | "${clean_dir}/${class_file}.conf", | |||||
| 1534 | "${clean_dir}/${class_file}.yml", | |||||
| 1535 | "${clean_dir}/${class_file}.yaml", | |||||
| 1536 | "${clean_dir}/${class_file}.json", | |||||
| 1537 | )) { | |||||
| 1538 | 1359 | 6012 | if(-r $pattern && -f $pattern) { | |||
| 1539 | 58 | 41 | $found = $pattern; | |||
| 1540 | 58 | 140 | last SEARCH; | |||
| 1541 | } | |||||
| 1542 | } | |||||
| 1543 | } | |||||
| 1544 | } | |||||
| 1545 | } | |||||
| 1546 | ||||||
| 1547 | # Cache stores undef for "not found"; callers must use exists, not defined. | |||||
| 1548 | 444 | 789 | return ($_find_cache{$cache_key} = $found); | |||
| 1549 | } | |||||
| 1550 | ||||||
| 1551 | # Purpose: Run as the forked watcher child. Polls %_config_file_stats and | |||||
| 1552 | # sends SIGUSR1 to the parent when any file changes. | |||||
| 1553 | # Entry: $interval >= 1 (seconds). $callback is unused in the child (it runs | |||||
| 1554 | # in the parent's SIGUSR1 handler). | |||||
| 1555 | # Exit: Never returns; terminates via SIGTERM/SIGINT handlers. | |||||
| 1556 | # Side: Modifies %_config_file_stats entries in the child's address space only. | |||||
| 1557 | sub _run_config_watcher { | |||||
| 1558 | 16 | 231 | my ($interval, $callback) = @_; | |||
| 1559 | ||||||
| 1560 | # SECURITY (S5): re-validate $interval in the child process. | |||||
| 1561 | # If the parent's fork() fires before enable_hot_reload() applies its own guard | |||||
| 1562 | # (race) or state is corrupted between fork and here, sleep(0) or sleep(-1) would | |||||
| 1563 | # cause a busy-loop that saturates the CPU. int() also prevents floating-point | |||||
| 1564 | # values like 0.001 from reaching the kernel as a near-zero sleep. | |||||
| 1565 | 16 | 486 | $interval = int($interval // $DEFAULT_INTERVAL); | |||
| 1566 | 16 | 345 | $interval = $DEFAULT_INTERVAL unless $interval > 0; | |||
| 1567 | ||||||
| 1568 | 16 16 | 1730 771 | local $SIG{TERM} = sub { exit 0 }; | |||
| 1569 | 16 0 | 578 0 | local $SIG{INT} = sub { exit 0 }; | |||
| 1570 | ||||||
| 1571 | 16 | 89 | while(1) { | |||
| 1572 | 18 | 2020544 | sleep($interval); | |||
| 1573 | ||||||
| 1574 | 18 | 312 | my $changes_detected = 0; | |||
| 1575 | ||||||
| 1576 | 2 | 44 | foreach my $config_file (keys %_config_file_stats) { | |||
| 1577 | 2 | 45 | if(-f $config_file) { | |||
| 1578 | 2 | 35 | my $current_stat = stat($config_file); | |||
| 1579 | 2 | 483 | my $stored_stat = $_config_file_stats{$config_file}; | |||
| 1580 | ||||||
| 1581 | 2 | 54 | if(!$stored_stat || $current_stat->mtime > $stored_stat->mtime) { | |||
| 1582 | 1 | 10 | $_config_file_stats{$config_file} = $current_stat; | |||
| 1583 | 1 | 16 | $changes_detected = 1; | |||
| 1584 | } | |||||
| 1585 | } else { | |||||
| 1586 | 0 | 0 | delete $_config_file_stats{$config_file}; | |||
| 1587 | 0 | 0 | $changes_detected = 1; | |||
| 1588 | } | |||||
| 1589 | } | |||||
| 1590 | ||||||
| 1591 | 2 | 43 | if($changes_detected && $^O ne $OS_WINDOWS) { | |||
| 1592 | 1 | 31 | if(my $parent_pid = getppid()) { | |||
| 1593 | 1 | 15 | kill('USR1', $parent_pid); | |||
| 1594 | } | |||||
| 1595 | } | |||||
| 1596 | } | |||||
| 1597 | } | |||||
| 1598 | ||||||
| 1599 | # Purpose: Reload a single object's configuration from disk and update its fields. | |||||
| 1600 | # Private properties (prefix '_') are intentionally skipped to avoid | |||||
| 1601 | # clobbering internal bookkeeping set at construction time. | |||||
| 1602 | # Entry: $obj must be a blessed reference with a {_config_file} or {_config_files} key. | |||||
| 1603 | # Exit: Returns nothing; updates $obj in-place. | |||||
| 1604 | # Side: Reads from disk. Calls $obj->_on_config_reload if the method exists. | |||||
| 1605 | sub _reload_object_config { | |||||
| 1606 | 82 | 29466 | my $obj = $_[0]; | |||
| 1607 | ||||||
| 1608 | 82 | 207 | return unless blessed($obj); | |||
| 1609 | ||||||
| 1610 | 80 | 109 | my $class = ref($obj); | |||
| 1611 | 80 | 61 | my $original_class = $class; | |||
| 1612 | 80 | 170 | $class =~ s/::/__/g; | |||
| 1613 | ||||||
| 1614 | # Prefer the most-specific (last) file from the full list; fall back to scalar key | |||||
| 1615 | 80 | 72 | my $config_file; | |||
| 1616 | 80 38 | 225 65 | if($obj->{_config_files} && ref($obj->{_config_files}) eq 'ARRAY' && @{ $obj->{_config_files} }) { | |||
| 1617 | 32 | 37 | $config_file = $obj->{_config_files}[-1]; | |||
| 1618 | } else { | |||||
| 1619 | 48 | 84 | $config_file = $obj->{_config_file} || $obj->{config_file}; | |||
| 1620 | } | |||||
| 1621 | ||||||
| 1622 | # SECURITY (S1 â path traversal): an attacker who modifies $obj->{_config_file} | |||||
| 1623 | # (e.g., via a deserialization gadget or a malicious config merge) can redirect | |||||
| 1624 | # hot-reload to read arbitrary system files. Reject paths with ".." segments here | |||||
| 1625 | # so that even a corrupted object cannot force a traversal read. | |||||
| 1626 | # Must come BEFORE the -f check so traversal paths for non-existent files are also rejected. | |||||
| 1627 | 80 | 262 | if($config_file && $config_file =~ $RE_PATH_TRAVERSAL) { | |||
| 1628 | 7 | 97 | carp(__PACKAGE__, ': _reload_object_config: refusing path with traversal sequences: ', | |||
| 1629 | $config_file); | |||||
| 1630 | 7 | 1000 | return; | |||
| 1631 | } | |||||
| 1632 | ||||||
| 1633 | 73 | 753 | return unless $config_file && -f $config_file; | |||
| 1634 | ||||||
| 1635 | 55 | 237 | my $config = Config::Abstraction->new( | |||
| 1636 | config_file => $config_file, | |||||
| 1637 | env_prefix => "${class}__" | |||||
| 1638 | ); | |||||
| 1639 | ||||||
| 1640 | 55 | 47213 | if($config) { | |||
| 1641 | 55 | 114 | my $new_params = $config->merge_defaults( | |||
| 1642 | defaults => {}, | |||||
| 1643 | section => $class, | |||||
| 1644 | merge => 1, | |||||
| 1645 | deep => 1 | |||||
| 1646 | ); | |||||
| 1647 | ||||||
| 1648 | 55 | 3561 | foreach my $key (keys %$new_params) { | |||
| 1649 | 100 | 112 | next if $key =~ /^_/; | |||
| 1650 | ||||||
| 1651 | 97 | 91 | if($key eq 'logger') { | |||
| 1652 | # Only the exact 'logger' key triggers logger reconstruction. | |||||
| 1653 | # Keys like 'logger.file' are flat config values, not logger specs. | |||||
| 1654 | # Guard: assign directly for undef (no logger) or literal 'NULL'. | |||||
| 1655 | # Premise 1: _build_logger handles every other spec type. | |||||
| 1656 | # Premise 2: undef/NULL need no construction. Conclusion: rebuild only otherwise. | |||||
| 1657 | 3 | 6 | my $val = $new_params->{$key}; | |||
| 1658 | 3 | 12 | if(!defined($val) || (!ref($val) && $val eq $LOGGER_NULL)) { | |||
| 1659 | 2 | 34 | $obj->{$key} = $val; | |||
| 1660 | } else { | |||||
| 1661 | 1 | 3 | _reconfigure_logger($obj, $key, $val); | |||
| 1662 | } | |||||
| 1663 | } else { | |||||
| 1664 | 94 | 99 | $obj->{$key} = $new_params->{$key}; | |||
| 1665 | } | |||||
| 1666 | } | |||||
| 1667 | ||||||
| 1668 | 55 | 259 | $obj->_on_config_reload($new_params) if $obj->can('_on_config_reload'); | |||
| 1669 | ||||||
| 1670 | $obj->{logger}->info("Configuration reloaded for $original_class") | |||||
| 1671 | 55 | 351 | if $obj->{logger} && $obj->{logger}->can('info'); | |||
| 1672 | } | |||||
| 1673 | ||||||
| 1674 | 55 | 1122 | return; | |||
| 1675 | } | |||||
| 1676 | ||||||
| 1677 | # Purpose: Replace the logger on an already-constructed object with one | |||||
| 1678 | # built from a new config value (typically a YAML hashref). | |||||
| 1679 | # Delegates to _build_logger so logger-creation logic lives in one place. | |||||
| 1680 | # Entry: $obj is a blessed hashref. $key is the hash key to update (usually 'logger'). | |||||
| 1681 | # $logger_config is the new spec from the config file. | |||||
| 1682 | # Exit: Returns nothing; updates $obj->{$key} in-place. | |||||
| 1683 | # Side: May allocate a new Log::Abstraction instance. | |||||
| 1684 | sub _reconfigure_logger | |||||
| 1685 | { | |||||
| 1686 | 10 | 14293 | my ($obj, $key, $logger_config) = @_; | |||
| 1687 | 10 | 24 | my $carp_on_warn = $obj->{carp_on_warn} || 0; | |||
| 1688 | 10 | 16 | $obj->{$key} = _build_logger($logger_config, $carp_on_warn); | |||
| 1689 | 10 | 6210 | return; | |||
| 1690 | } | |||||
| 1691 | ||||||
| 1692 | # Purpose: Right-precedence deep merge of two hash references. | |||||
| 1693 | # Scalar/arrayref values in $overlay replace those in $base entirely; | |||||
| 1694 | # nested hashrefs are merged recursively. | |||||
| 1695 | # Entry: Both args should be hashrefs (or undef/non-ref, handled gracefully). | |||||
| 1696 | # Exit: Returns a new hashref; neither input is modified. | |||||
| 1697 | sub _deep_merge { | |||||
| 1698 | 401 | 117914 | my ($base, $overlay) = @_; | |||
| 1699 | ||||||
| 1700 | 401 | 732 | return $overlay unless ref($base) eq 'HASH'; | |||
| 1701 | 384 | 431 | return $overlay unless ref($overlay) eq 'HASH'; | |||
| 1702 | ||||||
| 1703 | 364 | 478 | my $result = { %$base }; | |||
| 1704 | ||||||
| 1705 | 364 | 536 | foreach my $key (keys %$overlay) { | |||
| 1706 | 4547 | 3527 | if(ref($overlay->{$key}) eq 'HASH' && ref($result->{$key}) eq 'HASH') { | |||
| 1707 | 61 | 93 | $result->{$key} = _deep_merge($result->{$key}, $overlay->{$key}); | |||
| 1708 | } else { | |||||
| 1709 | 4486 | 3258 | $result->{$key} = $overlay->{$key}; | |||
| 1710 | } | |||||
| 1711 | } | |||||
| 1712 | ||||||
| 1713 | 364 | 1662 | return $result; | |||
| 1714 | } | |||||
| 1715 | ||||||
| 1716 | # Clean up the watcher child and restore signal state on interpreter exit. | |||||
| 1717 | END { | |||||
| 1718 | 36 | 2633765 | disable_hot_reload(); | |||
| 1719 | 36 | 99 | restore_signal_handlers(); | |||
| 1720 | } | |||||
| 1721 | ||||||
| 1722 - 1995 | =head1 SEE ALSO
=over 4
=item * L<Config::Abstraction>
=item * L<Log::Abstraction>
=item * L<Test Dashboard|https://nigelhorne.github.io/Object-Configure/coverage/>
=back
=head1 LIMITATIONS
=over 4
=item * B<Global singleton state.> C<%_object_registry>, C<%_config_watchers>, and
C<%_config_file_stats> are package globals. Two independent subsystems in the same
process share one hot-reload registry and one SIGUSR1 handler. There is no
instance-level isolation. A proper fix would wrap state in an object and allow
multiple independent C<Object::Configure> instances, but that would break the
existing constructor-call API (C<configure($class, \%params)>).
=item * B<Hot reload is Unix-only.> SIGUSR1 does not exist on Windows.
All signal-related paths are guarded with C<$^O ne 'MSWin32'>, so the module
loads on Windows but silently skips hot-reload registration.
=item * B<configure() is a God function.> At ~120 lines it handles arg validation,
config-file discovery, MRO walking, multi-file merging, env-var merging, logger
creation, and hot-reload bookkeeping. Future versions should decompose this into
smaller, independently testable units.
=item * B<_deep_merge reimplements CPAN.> L<Hash::Merge::Simple> or L<Hash::Merge>
provide tested, feature-complete deep merge. The internal C<_deep_merge> is 15
lines and correct for the current use, but does not handle arrayrefs (they are
replaced wholesale, not merged). If array-merge semantics are ever needed, switch
to a CPAN module.
=item * B<No encapsulation enforcement.> Private helpers (C<_build_logger>,
C<_get_inheritance_chain>, etc.) are accessible to any caller. L<Sub::Private>
(enforce mode) would make accidental external use a compile-time error. It is not
added here to avoid a smoker dependency on a less-common module.
=item * B<configure() signature is positional, instantiate() is named.> The two
public constructors have inconsistent calling conventions. Normalising them to named
args would require a deprecation cycle.
=item * B<mro::get_linear_isa and UNIVERSAL.> Perl's C<mro::get_linear_isa> does
not include C<UNIVERSAL> in its output unless C<UNIVERSAL> appears explicitly in
C<@ISA>. This module appends C<UNIVERSAL> manually so that C<universal.yml> is
always discovered. If a future Perl version changes this behaviour the guard
(C<grep { $_ eq 'UNIVERSAL' }>) remains correct.
=back
=head1 Formal Specification
=head2 configure
configure: Class x Params -> ConfigHash
Given:
- C: set of all class names
- P: set of all parameter hashes
- F: set of all file paths
- H: set of all configuration hashes
State:
- ConfigFiles: F -> H (maps file paths to configuration content)
- EnvVars: String -> String (environment variables)
- InheritanceChain: C -> seq C (ordered sequence of ancestor classes)
Pre-condition:
forall class in C, params in P:
class != empty
(params.config_file != empty =>
(exists dir in params.config_dirs: readable(dir/params.config_file))
OR readable(params.config_file))
Post-condition:
forall result in H:
result = params
(+) (merge f in InheritanceConfigFiles(class): ConfigFiles(f))
(+) (merge v in RelevantEnvVars(class): v)
result.logger in Log::Abstraction
(forall k in dom params:
(params(k) in CodeRef OR blessed(params(k))) => result(k) = params(k))
where (+) denotes hash merge with right-precedence
=head2 instantiate
instantiate: Params -> Object
Given:
- P: set of all parameter hashes
- C: set of all class names
- O: set of all objects
Pre-condition:
forall params in P:
params.class in C
params.class.can('new')
Post-condition:
forall result in O:
exists config in H:
config = configure(params.class, params)
result = params.class.new(config)
blessed(result) = params.class
(config._config_file != empty =>
result in _object_registry(params.class))
=head2 enable_hot_reload
enable_hot_reload: Interval x Callback -> PID
Given:
- I: set of positive integers (intervals in seconds)
- CB: set of code references
- PID: set of process identifiers
State:
- _config_watchers: {pid: PID, callback: CB}
- _config_file_stats: F -> Stat
Pre-condition:
forall interval in I, callback in CB union {empty}:
interval >= 1
_config_watchers = empty
OS != 'MSWin32'
Post-condition:
forall result in PID:
result > 0
_config_watchers.pid = result
_config_watchers.callback = callback
(forall t in Time:
(t mod interval = 0) =>
(exists f in dom _config_file_stats:
mtime(f) > _config_file_stats(f).mtime =>
send_signal(SIGUSR1, parent_process)))
=head2 disable_hot_reload
disable_hot_reload: () -> ()
State:
- _config_watchers: {pid: PID, callback: CB}
Pre-condition:
true
Post-condition:
_config_watchers = empty
(forall p in PID:
p = _config_watchers.pid@pre =>
NOT alive(p))
=head2 reload_config
reload_config: () -> N
State:
- _object_registry: C -> seq ObjectRef
- ConfigFiles: F -> H
Pre-condition:
true
Post-condition:
forall result in N:
result = |{obj in flatten(ran _object_registry) |
obj != empty
obj._config_file in dom ConfigFiles}|
(forall obj in flatten(ran _object_registry):
obj != empty AND obj._config_file in dom ConfigFiles =>
(forall k in dom ConfigFiles(obj._config_file):
k NOT in PrivateKeys =>
obj(k)@post = ConfigFiles(obj._config_file)(k)))
where PrivateKeys = {k | k starts with '_'}
=head2 register_object
register_object: C x O -> ()
Given:
- C: set of class names
- O: set of blessed objects
- OR: C -> seq WeakRef(O) (object registry)
State:
- _object_registry: OR
- _original_usr1_handler: SignalHandler union {empty}
- $SIG{USR1}: SignalHandler
Pre-condition:
forall class in C, obj in O:
class != empty
obj != empty
blessed(obj) != empty
Post-condition:
forall class in C, obj in O:
exists ref in _object_registry(class):
weak(ref) = obj
(_original_usr1_handler = empty@pre =>
(_original_usr1_handler@post = $SIG{USR1}@pre
$SIG{USR1}@post = reload_config_handler))
=head2 restore_signal_handlers
restore_signal_handlers: () -> ()
State:
- _original_usr1_handler: SignalHandler union {empty}
- $SIG{USR1}: SignalHandler
Pre-condition:
true
Post-condition:
$SIG{USR1}@post = _original_usr1_handler@pre
_original_usr1_handler@post = empty
=head2 get_signal_handler_info
get_signal_handler_info: () -> InfoHash
Given:
- IH: set of all info hashes
State:
- _original_usr1_handler: SignalHandler union {empty}
- $SIG{USR1}: SignalHandler union {empty}
- _config_watchers: {pid: PID, callback: CB}
Pre-condition:
true
Post-condition:
forall result in IH:
result.original_usr1 = _original_usr1_handler
result.current_usr1 = $SIG{USR1}
result.hot_reload_active = (_original_usr1_handler != empty)
result.watcher_pid = _config_watchers.pid
=head1 SUPPORT
Please report bugs and feature requests at:
=over 4
=item * RT (CPAN bug tracker): L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Object-Configure>
or by e-mail: C<bug-object-configure at rt.cpan.org>
=item * GitHub issues: L<https://github.com/nigelhorne/Object-Configure/issues>
=back
You will be notified automatically of progress on your report.
perldoc Object::Configure
=head1 LICENCE AND COPYRIGHT
Copyright 2025-2026 Nigel Horne.
Usage is subject to GPL2 licence terms.
If you use it, please let me know.
=cut | |||||
| 1996 | ||||||
| 1997 | 1; | |||||