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