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 }