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