File Coverage

blib/lib/Proc/Govern.pm
Criterion Covered Total %
statement 109 254 42.9
branch 29 122 23.7
condition 12 55 21.8
subroutine 13 22 59.0
pod 1 2 50.0
total 164 455 36.0


line stmt bran cond sub pod time code
1             package Proc::Govern;
2              
3             our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
4             our $DATE = '2020-08-18'; # DATE
5             our $DIST = 'Proc-Govern'; # DIST
6             our $VERSION = '0.210'; # VERSION
7              
8 1     1   71982 use 5.010001;
  1         11  
9 1     1   8 use strict;
  1         1  
  1         33  
10 1     1   6 use warnings;
  1         2  
  1         25  
11 1     1   1827 use Log::ger;
  1         49  
  1         5  
12              
13 1     1   267 use Exporter qw(import);
  1         2  
  1         50  
14             our @EXPORT_OK = qw(govern_process);
15              
16             our %SPEC;
17              
18 1     1   497 use IPC::Run::Patch::Setuid ();
  1         17835  
  1         26  
19 1     1   923 use IPC::Run (); # just so prereq can be detected
  1         43650  
  1         41  
20 1     1   590 use Time::HiRes qw(sleep);
  1         1540  
  1         5  
21              
22             sub new {
23 1     1 0 4 my ($class) = @_;
24 1         3 bless {}, $class;
25             }
26              
27             sub _suspend {
28 0     0   0 my $self = shift;
29 0         0 my $h = $self->{h};
30 0         0 log_debug "[govproc] Suspending child ...";
31 0 0       0 if (@{ $h->{KIDS} }) {
  0         0  
32 0         0 my @args = (STOP => (map { $_->{PID} } @{ $h->{KIDS} }));
  0         0  
  0         0  
33 0 0       0 if ($self->{args}{killfam}) {
34             #say "D:killfam ".join(" ", @args) if $self->{debug};
35 0         0 Proc::Killfam::killfam(@args);
36             } else {
37             #say "D:kill ".join(" ", @args) if $self->{debug};
38 0         0 kill @args;
39             }
40             }
41 0         0 $self->{suspended} = 1;
42             }
43              
44             sub _resume {
45 0     0   0 my $self = shift;
46 0         0 my $h = $self->{h};
47 0         0 log_debug "[govproc] Resuming child ...";
48 0 0       0 if (@{ $h->{KIDS} }) {
  0         0  
49 0         0 my @args = (CONT => (map { $_->{PID} } @{ $h->{KIDS} }));
  0         0  
  0         0  
50 0 0       0 if ($self->{args}{killfam}) {
51             #say "D:killfam ".join(" ", @args) if $self->{debug};
52 0         0 Proc::Killfam::killfam(@args);
53             } else {
54             #say "D:kill ".join(" ", @args) if $self->{debug};
55 0         0 kill @args;
56             }
57             }
58 0         0 $self->{suspended} = 0;
59             }
60              
61             sub _kill {
62 0     0   0 my $self = shift;
63 0         0 my $h = $self->{h};
64 0 0       0 $self->_resume if $self->{suspended};
65 0         0 log_debug "[govproc] Killing child ...";
66 0         0 $self->{restart} = 0;
67 0         0 $h->kill_kill;
68             }
69              
70             $SPEC{govern_process} = {
71             v => 1.1,
72             summary => 'Run child process and govern its various aspects',
73             description => <<'_',
74              
75             It basically uses and a loop to check various conditions during
76             the lifetime of the child process.
77              
78             TODO: restart_delay, check_alive.
79              
80             _
81             args => {
82             name => {
83             schema => 'str*',
84             description => <<'_',
85              
86             Should match regex `\A\w+\z`. Used in several places, e.g. passed as `prefix` in
87             's constructor as well as used as name of PID file.
88              
89             If not given, will be taken from command.
90              
91             _
92             },
93             command => {
94             schema => ['array*' => of => 'str*'],
95             req => 1,
96             pos => 0,
97             slurpy => 1,
98             summary => 'Command to run',
99             description => <<'_',
100              
101             Passed to 's `start()`.
102              
103             _
104             },
105             nice => {
106             summary => 'Set nice/priority level',
107             schema => ['int*'],
108             },
109             single_instance => {
110             schema => [bool => default => 0],
111             description => <<'_',
112              
113             If set to true, will prevent running multiple instances simultaneously.
114             Implemented using . You will also normally have to set
115             `pid_dir`, unless your script runs as root, in which case you can use the
116             default `/var/run`.
117              
118             _
119             tags => ['category:instance-control'],
120             },
121             pid_dir => {
122             summary => 'Directory to put PID file in',
123             schema => 'dirname*',
124             },
125             on_multiple_instance => {
126             schema => ['str*' => in => ['exit']],
127             description => <<'_',
128              
129             Can be set to `exit` to silently exit when there is already a running instance.
130             Otherwise, will print an error message `Program already running`.
131              
132             _
133             tags => ['category:instance-control'],
134             },
135             load_watch => {
136             schema => [bool => default => 0],
137             description => <<'_',
138              
139             If set to 1, enable load watching. Program will be suspended when system load is
140             too high and resumed if system load returns to a lower limit.
141              
142             _
143             tags => ['category:load-control'],
144             },
145             load_check_every => {
146             schema => [duration => {default => 10, 'x.perl.coerce_rules'=>['From_str::human']}],
147             summary => 'Frequency of load checking',
148             tags => ['category:load-control'],
149             },
150             load_high_limit => {
151             schema => ['any*' => of => [[int => default => 1.25], 'code*']],
152             description => <<'_',
153              
154             Limit above which program should be suspended, if load watching is enabled. If
155             integer, will be compared against `->load`'s `$load1` value.
156             Alternatively, you can provide a custom routine here, code should return true if
157             load is considered too high.
158              
159             Note: `load_watch` needs to be set to true first for this to be effective.
160              
161             _
162             tags => ['category:load-control'],
163             },
164             load_low_limit => {
165             schema => ['any*' => of => [[int => default => 0.25], 'code*']],
166             description => <<'_',
167              
168             Limit below which program should resume, if load watching is enabled. If
169             integer, will be compared against `->load`'s `$load1` value.
170             Alternatively, you can provide a custom routine here, code should return true if
171             load is considered low.
172              
173             Note: `load_watch` needs to be set to true first for this to be effective.
174              
175             _
176             tags => ['category:load-control'],
177             },
178             killfam => {
179             summary => 'Instead of kill, use killfam (kill family of process)',
180             schema => 'bool',
181             description => <<'_',
182              
183             This can be useful e.g. to control load more successfully, if the
184             load-generating processes are the subchildren of the one we're governing.
185              
186             This requires CPAN module, which is installed separately.
187              
188             _
189             },
190             log_stdout => {
191             summary => 'Will be passed as arguments to `File::Write::Rotate`',
192             schema => ['hash*' => keys => {
193             dir => 'str*',
194             size => 'str*',
195             histories => 'int*',
196             }],
197             tags => ['category:logging'],
198             },
199             show_stdout => {
200             schema => [bool => default => 1],
201             summary => 'Just like `show_stderr`, but for STDOUT',
202             tags => ['category:output-control'],
203             },
204             log_stderr => {
205             summary => 'Will be passed as arguments to `File::Write::Rotate`',
206             description => <<'_',
207              
208             Specify logging for STDERR. Logging will be done using .
209             Known hash keys: `dir` (STR, defaults to `/var/log`, directory, preferably
210             absolute, where the log file(s) will reside, should already exist and be
211             writable, will be passed to 's constructor), `size`
212             (int, also passed to 's constructor), `histories` (int,
213             also passed to 's constructor), `period` (str, also
214             passed to 's constructor).
215              
216             _
217             schema => ['hash*' => keys => {
218             dir => 'str*',
219             size => 'str*',
220             histories => 'int*',
221             }],
222             tags => ['category:logging'],
223             },
224             show_stderr => {
225             schema => ['bool'],
226             default => 1,
227             description => <<'_',
228              
229             Can be used to turn off STDERR output. If you turn this off and set
230             `log_stderr`, STDERR output will still be logged but not displayed to screen.
231              
232             _
233             tags => ['category:output-control'],
234             },
235             timeout => {
236             schema => ['duration*', 'x.perl.coerce_rules'=>['From_str::human']],
237             summary => 'Apply execution time limit, in seconds',
238             description => <<'_',
239              
240             After this time is reached, process (and all its descendants) are first sent the
241             TERM signal. If after 30 seconds pass some processes still survive, they are
242             sent the KILL signal.
243              
244             The killing is implemented using 's `kill_kill()`.
245              
246             Upon timeout, exit code is set to 124.
247              
248             _
249             tags => ['category:timeout'],
250             },
251             restart => {
252             schema => ['bool'],
253             summary => 'If set to true, do restart',
254             tags => ['category:restart'],
255             },
256             # not yet defined
257             #restart_delay => {
258             # schema => ['duration*', default=>0, 'x.perl.coerce_rules'=>['From_str::human']],
259             # tags => ['category:restart'],
260             #},
261             #check_alive => {
262             # # not yet defined, can supply a custom coderef, or specify some
263             # # standard checks like TCP/UDP connection to some port, etc.
264             # schema => 'any*',
265             #},
266             no_screensaver => {
267             summary => 'Prevent screensaver from being activated',
268             schema => ['true*'],
269             tags => ['category:screensaver'],
270             },
271             no_sleep => {
272             summary => 'Prevent system from sleeping',
273             schema => ['true*'],
274             tags => ['category:power-management'],
275             },
276             euid => {
277             summary => 'Set EUID of command process',
278             schema => 'unix::local_uid*',
279             description => <<'_',
280              
281             Need to be root to be able to setuid.
282              
283             _
284             tags => ['category:setuid'],
285             },
286             egid => {
287             summary => 'Set EGID(s) of command process',
288             schema => 'str*',
289             description => <<'_',
290              
291             Need to be root to be able to setuid.
292              
293             _
294             tags => ['category:setuid'],
295             },
296             },
297             args_rels => {
298             'dep_all&' => [
299             [pid_dir => ['single_instance']],
300             [load_low_limit => ['load_watch']], # XXX should only be allowed when load_watch is true
301             [load_high_limit => ['load_watch']], # XXX should only be allowed when load_watch is true
302             [load_check_every => ['load_watch']], # XXX should only be allowed when load_watch is true
303             ],
304              
305             },
306             result_naked => 1,
307             result => {
308             summary => "Child's exit code",
309             schema => 'int',
310             },
311             };
312             sub govern_process {
313 1     1 1 1274 my $self;
314 1 50       4 if (ref $_[0]) {
315 0         0 $self = shift;
316             } else {
317 1         7 $self = __PACKAGE__->new;
318             }
319              
320             # assign and check arguments
321 1         17 my %args = @_;
322 1         8 $self->{args} = \%args;
323 1 50       5 if (defined $args{euid}) {
324             # coerce from username
325 0 0       0 unless ($args{euid} =~ /\A[0-9]+\z/) {
326 0         0 my @pw = getpwnam $args{euid};
327 0 0       0 $args{euid} = $pw[2] if @pw;
328             }
329 0 0       0 $args{euid} =~ /\A[0-9]+\z/
330             or die "euid ('$args{euid}') has to be integer";
331             }
332 1 50       3 if (defined $args{egid}) {
333             # coerce from groupname
334 0 0       0 unless ($args{egid} =~ /\A[0-9]+( [0-9]+)*\z/) {
335 0         0 my @gr = getgrnam $args{egid};
336 0 0       0 $args{egid} = $gr[2] if @gr;
337             }
338 0 0       0 $args{egid} =~ /\A[0-9]+( [0-9]+)*\z/
339             or die "egid ('$args{egid}') has to be integer or ".
340             "integers separated by space";
341             }
342              
343 1 50       5 require Proc::Killfam if $args{killfam};
344 1 50       5 require Screensaver::Any if $args{no_screensaver};
345 1 50       3 require PowerManagement::Any if $args{no_sleep};
346              
347 1         2 my $exitcode;
348              
349 1         1 my $cmd = $args{command};
350 1 50       3 defined($cmd) or die "Please specify command";
351 1 50       4 ref($cmd) eq 'ARRAY' or die "Command must be arrayref of strings";
352              
353 1         3 my $name = $args{name};
354 1 50       4 if (!defined($name)) {
355 1         2 $name = $cmd->[0];
356 1         7 $name =~ s!.*/!!; $name =~ s/\W+/_/g;
  1         4  
357 1 50       3 length($name) or $name = "prog";
358             }
359 1 50       5 defined($name) or die "Please specify name";
360 1 50       5 $name =~ /\A\w+\z/ or die "Invalid name, please use letters/numbers only";
361 1         4 $self->{name} = $name;
362              
363 1 50       4 if ($args{single_instance}) {
364 0   0     0 my $pid_dir = $args{pid_dir} // "/var/run";
365 0         0 require Proc::PID::File;
366 0 0       0 if (Proc::PID::File->running(dir=>$pid_dir, name=>$name, verify=>1)) {
367 0 0 0     0 if ($args{on_multiple_instance} &&
368             $args{on_multiple_instance} eq 'exit') {
369 0         0 $exitcode = 202; goto EXIT;
  0         0  
370             } else {
371 0         0 warn "Program $name already running";
372 0         0 $exitcode = 202; goto EXIT;
  0         0  
373             }
374             }
375             }
376              
377 1   50     10 my $showout = $args{show_stdout} // 1;
378 1   50     5 my $showerr = $args{show_stderr} // 1;
379              
380 1   50     4 my $lw = $args{load_watch} // 0;
381 1   50     4 my $lwfreq = $args{load_check_every} // 10;
382 1   50     4 my $lwhigh = $args{load_high_limit} // 1.25;
383 1   50     5 my $lwlow = $args{load_low_limit} // 0.25;
384              
385 1         2 my $noss = $args{no_screensaver};
386 1         2 my $nosleep = $args{no_sleep};
387              
388             ###
389              
390 1         2 my $out;
391             LOG_STDOUT: {
392 1 50       2 if ($args{log_stdout}) {
  1         4  
393 0         0 require File::Write::Rotate;
394 0         0 my %fwrargs = %{$args{log_stdout}};
  0         0  
395 0   0     0 $fwrargs{dir} //= "/var/log";
396 0         0 $fwrargs{prefix} = $name;
397 0         0 my $fwr = File::Write::Rotate->new(%fwrargs);
398             $out = sub {
399 0 0 0 0   0 print STDOUT $_[0]//'' if $showout;
400             # XXX prefix with timestamp, how long script starts,
401 0         0 $_[0] =~ s/^/STDOUT: /mg;
402 0         0 $fwr->write($_[0]);
403 0         0 };
404             } else {
405             $out = sub {
406 1 50 50 1   1339 print STDOUT $_[0]//'' if $showout;
407 1         5 };
408             }
409             }
410              
411 1         2 my $err;
412             LOG_STDERR: {
413 1 50       1 if ($args{log_stderr}) {
  1         3  
414 0         0 require File::Write::Rotate;
415 0         0 my %fwrargs = %{$args{log_stderr}};
  0         0  
416 0   0     0 $fwrargs{dir} //= "/var/log";
417 0         0 $fwrargs{prefix} = $name;
418 0         0 my $fwr = File::Write::Rotate->new(%fwrargs);
419             $err = sub {
420 0 0 0 0   0 print STDERR $_[0]//'' if $showerr;
421             # XXX prefix with timestamp, how long script starts,
422 0         0 $_[0] =~ s/^/STDERR: /mg;
423 0         0 $fwr->write($_[0]);
424 0         0 };
425             } else {
426             $err = sub {
427 0 0 0 0   0 print STDERR $_[0]//'' if $showerr;
428 1         7 };
429             }
430             }
431              
432 1         2 my $prevented_sleep;
433             PREVENT_SLEEP: {
434 1 50       2 last unless $nosleep;
  1         4  
435 0         0 my $res = PowerManagement::Any::sleep_is_prevented();
436 0 0       0 unless ($res->[0] == 200) {
437 0         0 log_warn "Cannot check if sleep is being prevented (%s), ".
438             "will not be preventing sleep", $res;
439 0         0 last;
440             }
441 0 0       0 if ($res->[2]) {
442 0         0 log_info "Sleep is already being prevented";
443 0         0 last;
444             }
445 0         0 $res = PowerManagement::Any::prevent_sleep();
446 0 0 0     0 unless ($res->[0] == 200 || $res->[0] == 304) {
447 0         0 log_warn "Cannot prevent sleep (%s), will be running anyway", $res;
448 0         0 last;
449             }
450 0         0 log_info "Prevented sleep (%s)", $res;
451 0         0 $prevented_sleep++;
452             }
453              
454             my $do_unprevent_sleep = sub {
455 1 50   1   4 return unless $prevented_sleep;
456 0         0 my $res = PowerManagement::Any::unprevent_sleep();
457 0 0 0     0 unless ($res->[0] == 200 || $res->[0] == 304) {
458 0         0 log_warn "Cannot unprevent sleep (%s)", $res;
459             }
460 0         0 $prevented_sleep = 0;
461 1         5 };
462              
463 1         2 my $start_time; # for timeout
464 1         2 my ($to, $h);
465              
466             my $do_start = sub {
467 1     1   2 $start_time = time();
468             IPC::Run::Patch::Setuid->import(
469             -warn_target_loaded => 0,
470             -euid => $args{euid},
471             -egid => $args{egid},
472 1 50 33     8 ) if defined $args{euid} || defined $args{egid};
473              
474 1         9 log_debug "[govproc] (Re)starting program $name ...";
475 1         9 $to = IPC::Run::timeout(1);
476             #$self->{to} = $to;
477 1 50       241 $h = IPC::Run::start($cmd, \*STDIN, $out, $err, $to)
478             or die "Can't start program: $?";
479 1         7512 $self->{h} = $h;
480              
481 1 50       33 if (defined $args{nice}) {
482             log_debug "[govproc] Setting nice level of PID %d to %d ...",
483 0         0 $h->{KIDS}[0]{PID}, $args{nice};
484 0         0 setpriority(0, $h->{KIDS}[0]{PID}, $args{nice});
485             }
486              
487             IPC::Run::Patch::Setuid->unimport()
488 1 50 33     49 if defined $args{euid} || defined $args{egid};
489 1         6 };
490              
491 1         3 $do_start->();
492              
493             local $SIG{INT} = sub {
494 0     0   0 log_debug "[govproc] Received INT signal";
495 0         0 $self->_kill;
496 0         0 $do_unprevent_sleep->();
497 0         0 exit 1;
498 1         70 };
499              
500             local $SIG{TERM} = sub {
501 0     0   0 log_debug "[govproc] Received TERM signal";
502 0         0 $self->_kill;
503 0         0 $do_unprevent_sleep->();
504 0         0 exit 1;
505 1         47 };
506              
507 1         7 my $chld_handler;
508 1         13 $self->{restart} = $args{restart};
509             $chld_handler = sub {
510 0     0   0 $SIG{CHLD} = $chld_handler;
511 0 0       0 if ($self->{restart}) {
512 0         0 log_debug "[govproc] Child died";
513 0         0 $do_start->();
514             }
515 1         14 };
516 1 50       9 local $SIG{CHLD} = $chld_handler if $args{restart};
517              
518 1         3 my $lastlw_time;
519 1         2 my ($noss_screensaver, $noss_timeout, $noss_lastprevent_time);
520              
521             MAIN_LOOP:
522 1         2 while (1) {
523             #log_debug "[govproc] main loop";
524 3 50       9 if (!$self->{suspended}) {
525             # re-set timer, it might be reset by suspend/resume?
526 3         27 $to->start(1);
527              
528 3 100       1111 unless ($h->pumpable) {
529 1         34 $h->finish;
530 1         407 $exitcode = $h->result;
531 1         29 last MAIN_LOOP;
532             }
533              
534 2         22 eval { $h->pump };
  2         27  
535 2         1299 my $everr = $@;
536 2 50 33     21 die $everr if $everr && $everr !~ /^IPC::Run: timeout/;
537             } else {
538 0         0 sleep 1;
539             }
540 2         9 my $now = time();
541              
542 2 50       8 if (defined $args{timeout}) {
543 0 0       0 if ($now - $start_time >= $args{timeout}) {
544 0         0 $err->("Timeout ($args{timeout}s), killing child ...\n");
545 0         0 $self->_kill;
546             # mark with a special exit code that it's a timeout
547 0         0 $exitcode = 124;
548 0         0 last MAIN_LOOP;
549             }
550             }
551              
552 2 0 0     6 if ($lw && (!$lastlw_time || $lastlw_time <= ($now-$lwfreq))) {
      33        
553 0         0 log_debug "[govproc] Checking load";
554 0 0       0 if (!$self->{suspended}) {
555 0         0 my $is_high;
556 0 0       0 if (ref($lwhigh) eq 'CODE') {
557 0         0 $is_high = $lwhigh->($h);
558             } else {
559 0         0 require Unix::Uptime;
560 0         0 my @load = Unix::Uptime->load();
561 0         0 $is_high = $load[0] >= $lwhigh;
562             }
563 0 0       0 if ($is_high) {
564 0         0 log_debug "[govproc] Load is too high";
565 0         0 $self->_suspend;
566             }
567             } else {
568 0         0 my $is_low;
569 0 0       0 if (ref($lwlow) eq 'CODE') {
570 0         0 $is_low = $lwlow->($h);
571             } else {
572 0         0 require Unix::Uptime;
573 0         0 my @load = Unix::Uptime->load();
574 0         0 $is_low = $load[0] <= $lwlow;
575             }
576 0 0       0 if ($is_low) {
577 0         0 log_debug "[govproc] Load is low";
578 0         0 $self->_resume;
579             }
580             }
581 0         0 $lastlw_time = $now;
582             }
583              
584             NOSS:
585             {
586 2 50       4 last unless $noss;
  2         6  
587 0 0 0     0 last unless !$noss_lastprevent_time ||
588             $noss_lastprevent_time <= ($now-$noss_timeout+10);
589 0         0 log_debug "[govproc] Preventing screensaver from activating ...";
590 0 0       0 if (!$noss_lastprevent_time) {
591 0         0 $noss_screensaver = Screensaver::Any::detect_screensaver();
592 0 0       0 if (!$noss_screensaver) {
593 0         0 warn "Can't detect any known screensaver, ".
594             "will skip preventing screensaver from activating";
595 0         0 $noss = 0;
596 0         0 last NOSS;
597             }
598 0         0 my $res = Screensaver::Any::get_screensaver_timeout(
599             screensaver => $noss_screensaver,
600             );
601 0 0       0 if ($res->[0] != 200) {
602 0         0 warn "Can't get screensaver timeout ($res->[0]: $res->[1])".
603             ", will skip preventing screensaver from activating";
604 0         0 $noss = 0;
605 0         0 last NOSS;
606             }
607 0         0 $noss_timeout = $res->[2];
608             }
609 0         0 my $res = Screensaver::Any::prevent_screensaver_activated(
610             screensaver => $noss_screensaver,
611             );
612 0 0       0 if ($res->[0] != 200) {
613 0         0 warn "Can't prevent screensaver from activating ".
614             "($res->[0]: $res->[1])";
615             }
616 0         0 $noss_lastprevent_time = $now;
617             }
618              
619             } # MAINLOOP
620              
621 1         4 $do_unprevent_sleep->();
622              
623 1   50     61 EXIT:
624             return $exitcode || 0;
625             }
626              
627             1;
628             # ABSTRACT: Run child process and govern its various aspects
629              
630             __END__