File Coverage

blib/lib/Proc/Govern.pm
Criterion Covered Total %
statement 113 263 42.9
branch 30 126 23.8
condition 12 55 21.8
subroutine 13 22 59.0
pod 1 2 50.0
total 169 468 36.1


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