1 /**
2 Copyright: Copyright (c) 2019, Joakim Brännström. All rights reserved.
3 License: MPL-2
4 Author: Joakim Brännström (joakim.brannstrom@gmx.com)
5 
6 This Source Code Form is subject to the terms of the Mozilla Public License,
7 v.2.0. If a copy of the MPL was not distributed with this file, You can obtain
8 one at http://mozilla.org/MPL/2.0/.
9 
10 A runner that can execute all the users test commands. These can be either
11 manually specified or automatically detected.
12 */
13 module dextool.plugin.mutate.backend.test_mutant.test_cmd_runner;
14 
15 import logger = std.experimental.logger;
16 import core.sync.condition;
17 import core.sync.mutex;
18 import std.algorithm : filter, map, joiner;
19 import std.array : appender, Appender, empty, array;
20 import std.datetime : Duration, dur, Clock;
21 import std.exception : collectException;
22 import std.format : format;
23 import std.parallelism : TaskPool, Task, task;
24 import std.random : randomCover;
25 import std.range : take;
26 import std.typecons : Tuple;
27 
28 import my.named_type;
29 import my.path : AbsolutePath, Path;
30 import my.set;
31 import proc;
32 
33 import dextool.plugin.mutate.type : ShellCommand;
34 import dextool.plugin.mutate.backend.type : ExitStatus;
35 
36 version (unittest) {
37     import unit_threaded.assertions;
38 }
39 
40 @safe:
41 
42 struct TestRunner {
43     alias MaxCaptureBytes = NamedType!(ulong, Tag!"MaxOutputCaptureBytes",
44             ulong.init, TagStringable);
45     alias MinAvailableMemBytes = NamedType!(ulong, Tag!"MinAvailableMemBytes",
46             ulong.min, TagStringable);
47 
48     private {
49         alias TestTask = Task!(spawnRunTest, ShellCommand, Duration, string[string],
50                 MaxCaptureBytes, MinAvailableMemBytes, Signal, Mutex, Condition);
51         TaskPool pool;
52         bool ownsPool;
53         Duration timeout_;
54 
55         Signal earlyStopSignal;
56 
57         /// Commands that execute the test cases.
58         alias TestCmd = Tuple!(ShellCommand, "cmd", double, "kills");
59         TestCmd[] commands;
60         long nrOfRuns;
61 
62         /// Environment to set when executing either binaries or the command.
63         string[string] env;
64 
65         bool captureAllOutput;
66 
67         /// max bytes to save from a test case.
68         MaxCaptureBytes maxOutput = 10 * 1024 * 1024;
69 
70         MinAvailableMemBytes minAvailableMem_;
71     }
72 
73     static auto make(int poolSize) {
74         return TestRunner(poolSize);
75     }
76 
77     this(int poolSize_) {
78         this.ownsPool = true;
79         this.poolSize(poolSize_);
80         this.earlyStopSignal = new Signal(false);
81     }
82 
83     this(TaskPool pool, Duration timeout_, TestCmd[] commands, long nrOfRuns,
84             bool captureAllOutput, MaxCaptureBytes maxOutput, MinAvailableMemBytes minAvailableMem_) {
85         this.pool = pool;
86         this.timeout_ = timeout_;
87         this.earlyStopSignal = new Signal(false);
88         this.commands = commands;
89         this.nrOfRuns = nrOfRuns;
90         this.captureAllOutput = captureAllOutput;
91         this.maxOutput = maxOutput;
92         this.minAvailableMem_ = minAvailableMem_;
93     }
94 
95     ~this() {
96         if (ownsPool)
97             pool.stop;
98     }
99 
100     TestRunner dup() {
101         return TestRunner(pool, timeout_, commands, nrOfRuns, captureAllOutput,
102                 maxOutput, minAvailableMem_);
103     }
104 
105     string[string] getDefaultEnv() @safe pure nothrow @nogc {
106         return env;
107     }
108 
109     void defaultEnv(string[string] env) @safe pure nothrow @nogc {
110         this.env = env;
111     }
112 
113     void maxOutputCapture(MaxCaptureBytes bytes) @safe pure nothrow @nogc {
114         this.maxOutput = bytes;
115     }
116 
117     void minAvailableMem(MinAvailableMemBytes bytes) @safe pure nothrow @nogc {
118         this.minAvailableMem_ = bytes;
119     }
120 
121     /** Stop executing tests as soon as one detects a failure.
122      *
123      * This lose some information about the test cases but mean that mutation
124      * testing overall is executing faster.
125      */
126     void useEarlyStop(bool v) @safe nothrow {
127         this.earlyStopSignal = new Signal(v);
128     }
129 
130     void captureAll(bool v) @safe pure nothrow @nogc {
131         this.captureAllOutput = v;
132     }
133 
134     bool empty() @safe pure nothrow const @nogc {
135         return commands.length == 0;
136     }
137 
138     void poolSize(const int s) @safe {
139         if (pool !is null) {
140             pool.stop;
141         }
142         if (s == 0) {
143             pool = new TaskPool;
144         } else {
145             pool = new TaskPool(s);
146         }
147         pool.isDaemon = true;
148     }
149 
150     void timeout(Duration timeout) pure nothrow @nogc {
151         this.timeout_ = timeout;
152     }
153 
154     void put(ShellCommand sh) pure nothrow {
155         if (!sh.value.empty)
156             commands ~= TestCmd(sh, 0);
157     }
158 
159     void put(ShellCommand[] shs) pure nothrow {
160         foreach (a; shs)
161             put(a);
162     }
163 
164     TestCmd[] testCmds() @safe pure nothrow @nogc {
165         return commands;
166     }
167 
168     TestResult run() {
169         return this.run(timeout_, null, SkipTests.init);
170     }
171 
172     TestResult run(SkipTests skipTests) {
173         return this.run(timeout_, null, skipTests);
174     }
175 
176     TestResult run(string[string] localEnv) {
177         return this.run(timeout_, localEnv, SkipTests.init);
178     }
179 
180     TestResult run(Duration timeout, string[string] localEnv = null,
181             SkipTests skipTests = SkipTests.init) {
182         import core.thread : Thread;
183         import core.time : dur;
184         import std.range : enumerate;
185 
186         static TestTask* findDone(ref TestTask*[] tasks) {
187             bool found;
188             size_t idx;
189             foreach (t; tasks.enumerate.filter!(a => a.value.done)) {
190                 idx = t.index;
191                 found = true;
192                 break;
193             }
194 
195             if (found) {
196                 auto t = tasks[idx];
197                 tasks[idx] = tasks[$ - 1];
198                 tasks = tasks[0 .. $ - 1];
199                 return t;
200             }
201             return null;
202         }
203 
204         void processDone(TestTask* t, ref TestResult result) {
205             auto res = t.yieldForce;
206 
207             result.exitStatus = mergeExitStatus(result.exitStatus, res.exitStatus);
208 
209             final switch (res.status) {
210             case RunResult.Status.normal:
211                 if (result.status == TestResult.Status.passed && res.exitStatus.get != 0) {
212                     result.status = TestResult.Status.failed;
213                 }
214                 if (res.exitStatus.get != 0) {
215                     incrCmdKills(res.cmd);
216                     result.output[res.cmd] = res.output;
217                 } else if (captureAllOutput && res.exitStatus.get == 0) {
218                     result.output[res.cmd] = res.output;
219                 }
220                 break;
221             case RunResult.Status.timeout:
222                 result.status = TestResult.Status.timeout;
223                 break;
224             case RunResult.Status.memOverload:
225                 result.status = TestResult.Status.memOverload;
226                 break;
227             case RunResult.Status.error:
228                 result.status = TestResult.Status.error;
229                 break;
230             }
231         }
232 
233         auto env_ = env;
234         foreach (kv; localEnv.byKeyValue) {
235             env_[kv.key] = kv.value;
236         }
237 
238         const reorderWhen = 10;
239 
240         scope (exit)
241             nrOfRuns++;
242         if (nrOfRuns == 0) {
243             commands = commands.randomCover.array;
244         } else if (nrOfRuns % reorderWhen == 0) {
245             // use a forget factor to make the order re-arrange over time
246             // if the "best" test case change.
247             foreach (ref a; commands) {
248                 a.kills = a.kills * 0.9;
249             }
250 
251             import std.algorithm : sort;
252 
253             // use those that kill the most first
254             commands = commands.sort!((a, b) => a.kills > b.kills).array;
255             logger.infof("Update test command order: %(%s, %)",
256                     commands.take(reorderWhen).map!(a => format("%s:%.2f", a.cmd, a.kills)));
257         }
258 
259         auto mtx = new Mutex;
260         auto condDone = new Condition(mtx);
261         earlyStopSignal.reset;
262         TestTask*[] tasks = startTests(timeout, env_, skipTests, mtx, condDone);
263         TestResult rval;
264         while (!tasks.empty) {
265             auto t = findDone(tasks);
266             if (t !is null) {
267                 processDone(t, rval);
268                 .destroy(t);
269             } else {
270                 synchronized (mtx) {
271                     () @trusted { condDone.wait(10.dur!"msecs"); }();
272                 }
273             }
274         }
275 
276         return rval;
277     }
278 
279     private auto startTests(Duration timeout, string[string] env,
280             SkipTests skipTests, Mutex mtx, Condition condDone) @trusted {
281         auto tasks = appender!(TestTask*[])();
282 
283         foreach (c; commands.filter!(a => a.cmd.value[0]!in skipTests.get)) {
284             auto t = task!spawnRunTest(c.cmd, timeout, env, maxOutput,
285                     minAvailableMem_, earlyStopSignal, mtx, condDone);
286             tasks.put(t);
287             pool.put(t);
288         }
289         return tasks.data;
290     }
291 
292     /// Find the test command and update its kill counter.
293     private void incrCmdKills(ShellCommand cmd) {
294         foreach (ref a; commands) {
295             if (a.cmd == cmd) {
296                 a.kills++;
297                 break;
298             }
299         }
300     }
301 }
302 
303 alias SkipTests = NamedType!(Set!string, Tag!"SkipTests", Set!string.init, TagStringable);
304 
305 /// The result of running the tests.
306 struct TestResult {
307     enum Status {
308         /// All test commands exited with exit status zero.
309         passed,
310         /// At least one test command indicated that an error where found by exit status not zero.
311         failed,
312         /// At least one test command timed out.
313         timeout,
314         /// memory overload
315         memOverload,
316         /// Something happend when the test command executed thus the result should not be used.
317         error
318     }
319 
320     Status status;
321     ExitStatus exitStatus;
322 
323     /// Output from all test binaries and command with exist status != 0.
324     DrainElement[][ShellCommand] output;
325 }
326 
327 /// Finds all executables in a directory tree.
328 string[] findExecutables(AbsolutePath root) @trusted {
329     import core.sys.posix.sys.stat;
330     import std.file : getAttributes;
331     import std.file : dirEntries, SpanMode;
332     import my.file : isExecutable;
333 
334     auto app = appender!(string[])();
335     foreach (f; dirEntries(root, SpanMode.breadth).filter!(a => a.isFile)
336             .filter!(a => isExecutable(Path(a.name)))) {
337         app.put([f.name]);
338     }
339 
340     return app.data;
341 }
342 
343 RunResult spawnRunTest(ShellCommand cmd, Duration timeout, string[string] env, TestRunner.MaxCaptureBytes maxOutputCapture,
344         TestRunner.MinAvailableMemBytes minAvailableMem, Signal earlyStop,
345         Mutex mtx, Condition condDone) @trusted nothrow {
346     import std.algorithm : copy;
347     static import std.process;
348 
349     auto availMem = AvailableMem.make();
350     scope (exit)
351         () {
352         try {
353             .destroy(availMem);
354         } catch (Exception e) {
355         }
356     }();
357     bool isMemLimitTrigger() {
358         return availMem.available < minAvailableMem.get;
359     }
360 
361     scope (exit)
362         () nothrow{
363         try {
364             synchronized (mtx) {
365                 condDone.notify;
366             }
367         } catch (Exception e) {
368         }
369     }();
370 
371     RunResult rval;
372     rval.cmd = cmd;
373 
374     if (earlyStop.isActive) {
375         debug logger.tracef("Early stop detected. Skipping %s (%s)", cmd,
376                 Clock.currTime).collectException;
377         return rval;
378     }
379 
380     try {
381         auto p = pipeProcess(cmd.value, std.process.Redirect.all, env).sandbox.timeout(timeout)
382             .rcKill;
383         auto output = appender!(DrainElement[])();
384         ulong outputBytes;
385         foreach (a; p.process.drain) {
386             if (!a.empty && (outputBytes + a.data.length) < maxOutputCapture.get) {
387                 output.put(a);
388                 outputBytes += a.data.length;
389             }
390             if (earlyStop.isActive) {
391                 debug logger.tracef("Early stop detected. Stopping %s (%s)", cmd, Clock.currTime);
392                 p.kill;
393                 break;
394             }
395             if (isMemLimitTrigger) {
396                 logger.infof("Available memory below limit. Stopping %s (%s < %s)",
397                         cmd, availMem.available, minAvailableMem.get);
398                 p.kill;
399                 rval.status = RunResult.Status.memOverload;
400                 break;
401             }
402         }
403 
404         if (p.timeoutTriggered) {
405             rval.status = RunResult.Status.timeout;
406         }
407 
408         rval.exitStatus = p.wait.ExitStatus;
409         rval.output = output.data;
410     } catch (Exception e) {
411         logger.warning(cmd).collectException;
412         logger.warning(e.msg).collectException;
413         rval.status = RunResult.Status.error;
414     }
415 
416     if (rval.exitStatus.get != 0) {
417         earlyStop.activate;
418         debug logger.tracef("Early stop triggered by %s (%s)", rval.cmd,
419                 Clock.currTime).collectException;
420     }
421 
422     return rval;
423 }
424 
425 private:
426 
427 struct RunResult {
428     enum Status {
429         /// the test command successfully executed.
430         normal,
431         /// Something happend when the test command executed thus the result should not be used.
432         error,
433         /// The test command timed out.
434         timeout,
435         /// memory overload
436         memOverload
437     }
438 
439     /// The command that where executed.
440     ShellCommand cmd;
441 
442     Status status;
443     ///
444     ExitStatus exitStatus;
445     ///
446     DrainElement[] output;
447 }
448 
449 string makeUnittestScript(string script, string file = __FILE__, uint line = __LINE__) {
450     import core.sys.posix.sys.stat;
451     import std.file : getAttributes, setAttributes, thisExePath;
452     import std.stdio : File;
453     import std.path : baseName;
454     import std.conv : to;
455 
456     immutable fname = thisExePath ~ "_" ~ script ~ file.baseName ~ line.to!string ~ ".sh";
457 
458     File(fname, "w").writeln(`#!/bin/bash
459 echo $1
460 if [[ "$3" = "timeout" ]]; then
461     sleep 10m
462 fi
463 exit $2`);
464     setAttributes(fname, getAttributes(fname) | S_IXUSR | S_IXGRP | S_IXOTH);
465     return fname;
466 }
467 
468 version (unittest) {
469     import core.time : dur;
470     import std.algorithm : count;
471     import std.array : array, empty;
472     import std.file : remove, exists;
473     import std.string : strip;
474 }
475 
476 @("shall collect the result from the test commands when running them")
477 unittest {
478     immutable script = makeUnittestScript("script_");
479     scope (exit)
480         () {
481         if (exists(script))
482             remove(script);
483     }();
484 
485     auto runner = TestRunner.make(0);
486     runner.captureAll(true);
487     runner.put([script, "foo", "0"].ShellCommand);
488     runner.put([script, "foo", "0"].ShellCommand);
489     auto res = runner.run(5.dur!"seconds");
490 
491     res.output.byKey.count.shouldEqual(1);
492     res.output.byValue.filter!"!a.empty".count.shouldEqual(1);
493     res.output.byValue.joiner.filter!(a => a.byUTF8.array.strip == "foo").count.shouldEqual(1);
494     res.status.shouldEqual(TestResult.Status.passed);
495 }
496 
497 @("shall set the status to failed when one of test command fail")
498 unittest {
499     immutable script = makeUnittestScript("script_");
500     scope (exit)
501         () {
502         if (exists(script))
503             remove(script);
504     }();
505 
506     auto runner = TestRunner.make(0);
507     runner.put([script, "foo", "0"].ShellCommand);
508     runner.put([script, "foo", "1"].ShellCommand);
509     auto res = runner.run(5.dur!"seconds");
510 
511     res.output.byKey.count.shouldEqual(1);
512     res.output.byValue.joiner.filter!(a => a.byUTF8.array.strip == "foo").count.shouldEqual(1);
513     res.status.shouldEqual(TestResult.Status.failed);
514 }
515 
516 @("shall set the status to timeout when one of the tests commands reach the timeout limit")
517 unittest {
518     immutable script = makeUnittestScript("script_");
519     scope (exit)
520         () {
521         if (exists(script))
522             remove(script);
523     }();
524 
525     auto runner = TestRunner.make(0);
526     runner.put([script, "foo", "0"].ShellCommand);
527     runner.put([script, "foo", "0", "timeout"].ShellCommand);
528     auto res = runner.run(1.dur!"seconds");
529 
530     res.status.shouldEqual(TestResult.Status.timeout);
531     res.output.byKey.count.shouldEqual(0); // no output should be saved
532 }
533 
534 @("shall only capture at most ")
535 unittest {
536     import std.algorithm : sum;
537 
538     immutable script = makeUnittestScript("script_");
539     scope (exit)
540         () {
541         if (exists(script))
542             remove(script);
543     }();
544 
545     auto runner = TestRunner.make(0);
546     runner.put([script, "my_output", "1"].ShellCommand);
547 
548     // capture up to default max.
549     auto res = runner.run(1.dur!"seconds");
550     res.output.byValue.joiner.map!(a => a.data.length).sum.shouldEqual(10);
551 
552     // reduce max and then nothing is captured because the minimum output is 9 byte
553     runner.maxOutputCapture(TestRunner.MaxCaptureBytes(4));
554     res = runner.run(1.dur!"seconds");
555     res.output.byValue.joiner.map!(a => a.data.length).sum.shouldEqual(0);
556 }
557 
558 /// Thread safe signal.
559 class Signal {
560     import core.atomic : atomicLoad, atomicStore;
561 
562     shared int state;
563     immutable bool isUsed;
564 
565     this(bool isUsed) @safe pure nothrow @nogc {
566         this.isUsed = isUsed;
567     }
568 
569     bool isActive() @safe nothrow @nogc const {
570         if (!isUsed)
571             return false;
572 
573         auto local = atomicLoad(state);
574         return local != 0;
575     }
576 
577     void activate() @safe nothrow @nogc {
578         atomicStore(state, 1);
579     }
580 
581     void reset() @safe nothrow @nogc {
582         atomicStore(state, 0);
583     }
584 }
585 
586 /// Merge the new exit code with the old one keeping the dominant.
587 ExitStatus mergeExitStatus(ExitStatus old, ExitStatus new_) {
588     import std.algorithm : max, min;
589 
590     if (old.get == 0)
591         return new_;
592 
593     if (old.get < 0) {
594         return min(old.get, new_.get).ExitStatus;
595     }
596 
597     // a value 128+n is a value from the OS which is pretty bad such as a segmentation fault.
598     // those <128 are user created.
599     return max(old.get, new_.get).ExitStatus;
600 }
601 
602 struct AvailableMem {
603     import std.conv : to;
604     import std.datetime : SysTime;
605     import std.stdio : File;
606     import std.string : startsWith, split;
607 
608     static const Duration pollFreq = 5.dur!"seconds";
609     File procMem;
610     SysTime nextPoll;
611     long current = long.max;
612 
613     static AvailableMem* make() @safe nothrow {
614         try {
615             return new AvailableMem(File("/proc/meminfo"), Clock.currTime);
616         } catch (Exception e) {
617             logger.warning("Unable to open /proc/meminfo").collectException;
618         }
619         return new AvailableMem(File.init, Clock.currTime);
620     }
621 
622     long available() @trusted nothrow {
623         if (Clock.currTime > nextPoll && procMem.isOpen) {
624             try {
625                 procMem.rewind;
626                 procMem.flush;
627                 foreach (l; procMem.byLine
628                         .filter!(l => l.startsWith("MemAvailable"))
629                         .map!(a => a.split)
630                         .filter!(a => a.length >= 3)) {
631                     current = to!long(l[1]) * 1024;
632                     break;
633                 }
634             } catch (Exception e) {
635                 current = long.max;
636             }
637             nextPoll = Clock.currTime + pollFreq;
638         }
639 
640         return current;
641     }
642 }