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