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 import my.path : AbsolutePath, Path; 28 29 import dextool.plugin.mutate.type : ShellCommand; 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 import my.file : isExecutable; 243 244 auto app = appender!(string[])(); 245 foreach (f; dirEntries(root, SpanMode.breadth).filter!(a => a.isFile) 246 .filter!(a => isExecutable(Path(a.name)))) { 247 app.put([f.name]); 248 } 249 250 return app.data; 251 } 252 253 RunResult spawnRunTest(string[] cmd, Duration timeout, string[string] env, Signal earlyStop) @trusted nothrow { 254 import std.algorithm : copy; 255 static import std.process; 256 257 RunResult rval; 258 rval.cmd = cmd; 259 260 if (earlyStop.isActive) { 261 debug logger.tracef("Early stop detected. Skipping %s (%s)", cmd, 262 Clock.currTime).collectException; 263 return rval; 264 } 265 266 try { 267 auto p = pipeProcess(cmd, std.process.Redirect.all, env).sandbox.timeout(timeout).scopeKill; 268 auto output = appender!(DrainElement[])(); 269 foreach (a; p.process.drain) { 270 if (!a.empty) { 271 output.put(a); 272 } 273 if (earlyStop.isActive) { 274 debug logger.tracef("Early stop detected. Stopping %s (%s)", cmd, Clock.currTime); 275 p.kill; 276 break; 277 } 278 } 279 280 if (p.timeoutTriggered) { 281 rval.status = RunResult.Status.timeout; 282 } 283 284 rval.exitStatus = p.wait; 285 rval.output = output.data; 286 } catch (Exception e) { 287 logger.warning(e.msg).collectException; 288 rval.status = RunResult.Status.error; 289 } 290 291 if (rval.exitStatus != 0) { 292 earlyStop.activate; 293 debug logger.tracef("Early stop triggered by %s (%s)", rval.cmd, 294 Clock.currTime).collectException; 295 } 296 297 return rval; 298 } 299 300 private: 301 302 struct RunResult { 303 enum Status { 304 /// the test command successfully executed. 305 normal, 306 /// Something happend when the test command executed thus the result should not be used. 307 error, 308 /// The test command timed out. 309 timeout, 310 } 311 312 /// The command that where executed. 313 string[] cmd; 314 315 Status status; 316 /// 317 int exitStatus; 318 /// 319 DrainElement[] output; 320 } 321 322 string makeUnittestScript(string script, string file = __FILE__, uint line = __LINE__) { 323 import core.sys.posix.sys.stat; 324 import std.file : getAttributes, setAttributes, thisExePath; 325 import std.stdio : File; 326 import std.path : baseName; 327 import std.conv : to; 328 329 immutable fname = thisExePath ~ "_" ~ script ~ file.baseName ~ line.to!string ~ ".sh"; 330 331 File(fname, "w").writeln(`#!/bin/bash 332 echo $1 333 if [[ "$3" = "timeout" ]]; then 334 sleep 10m 335 fi 336 exit $2`); 337 setAttributes(fname, getAttributes(fname) | S_IXUSR | S_IXGRP | S_IXOTH); 338 return fname; 339 } 340 341 version (unittest) { 342 import core.time : dur; 343 import std.algorithm : count; 344 import std.array : array, empty; 345 import std.file : remove, exists; 346 import std.string : strip; 347 } 348 349 @("shall collect the result from the test commands when running them") 350 unittest { 351 immutable script = makeUnittestScript("script_"); 352 scope (exit) 353 () { 354 if (exists(script)) 355 remove(script); 356 }(); 357 358 auto runner = TestRunner.make(0); 359 runner.put([script, "foo", "0"].ShellCommand); 360 runner.put([script, "foo", "0"].ShellCommand); 361 auto res = runner.run(5.dur!"seconds"); 362 363 res.output.filter!(a => !a.empty).count.shouldEqual(2); 364 res.output.filter!(a => a.byUTF8.array.strip == "foo").count.shouldEqual(2); 365 res.status.shouldEqual(TestResult.Status.passed); 366 } 367 368 @("shall set the status to failed when one of test command fail") 369 unittest { 370 immutable script = makeUnittestScript("script_"); 371 scope (exit) 372 () { 373 if (exists(script)) 374 remove(script); 375 }(); 376 377 auto runner = TestRunner.make(0); 378 runner.put([script, "foo", "0"].ShellCommand); 379 runner.put([script, "foo", "1"].ShellCommand); 380 auto res = runner.run(5.dur!"seconds"); 381 382 res.output.filter!(a => !a.empty).count.shouldEqual(2); 383 res.output.filter!(a => a.byUTF8.array.strip == "foo").count.shouldEqual(2); 384 res.status.shouldEqual(TestResult.Status.failed); 385 } 386 387 @("shall set the status to timeout when one of the tests commands reach the timeout limit") 388 unittest { 389 immutable script = makeUnittestScript("script_"); 390 scope (exit) 391 () { 392 if (exists(script)) 393 remove(script); 394 }(); 395 396 auto runner = TestRunner.make(0); 397 runner.put([script, "foo", "0"].ShellCommand); 398 runner.put([script, "foo", "0", "timeout"].ShellCommand); 399 auto res = runner.run(1.dur!"seconds"); 400 401 res.output.filter!(a => a.byUTF8.array.strip == "foo").count.shouldEqual(1); 402 res.status.shouldEqual(TestResult.Status.timeout); 403 } 404 405 /// Thread safe signal. 406 class Signal { 407 import core.atomic : atomicLoad, atomicStore; 408 409 shared int state; 410 immutable bool isUsed; 411 412 this(bool isUsed) @safe pure nothrow @nogc { 413 this.isUsed = isUsed; 414 } 415 416 bool isActive() @safe nothrow @nogc const { 417 if (!isUsed) 418 return false; 419 420 auto local = atomicLoad(state); 421 return local != 0; 422 } 423 424 void activate() @safe nothrow @nogc { 425 atomicStore(state, 1); 426 } 427 428 void reset() @safe nothrow @nogc { 429 atomicStore(state, 0); 430 } 431 }