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