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 }