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