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 core.sync.condition; 16 import core.sync.mutex; 17 import logger = std.experimental.logger; 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.file : SpanMode; 23 import std.format : format; 24 import std.parallelism : TaskPool, Task, task; 25 import std.random : randomCover; 26 import std.range : take; 27 import std.typecons : Tuple; 28 29 import my.named_type; 30 import my.path : AbsolutePath, Path; 31 import my.set; 32 import proc; 33 34 import dextool.plugin.mutate.type : ShellCommand; 35 import dextool.plugin.mutate.backend.type : ExitStatus; 36 37 version (unittest) { 38 import unit_threaded.assertions; 39 } 40 41 @safe: 42 43 struct TestRunner { 44 alias MaxCaptureBytes = NamedType!(ulong, Tag!"MaxOutputCaptureBytes", 45 ulong.init, TagStringable); 46 alias MinAvailableMemBytes = NamedType!(ulong, Tag!"MinAvailableMemBytes", 47 ulong.min, TagStringable); 48 49 private { 50 alias TestTask = Task!(spawnRunTest, ShellCommand, Duration, string[string], 51 MaxCaptureBytes, MinAvailableMemBytes, Signal, Mutex, Condition); 52 TaskPool pool; 53 bool ownsPool; 54 Duration timeout_; 55 56 Signal earlyStopSignal; 57 58 /// Commands that execute the test cases. 59 alias TestCmd = Tuple!(ShellCommand, "cmd", double, "kills"); 60 TestCmd[] commands; 61 long nrOfRuns; 62 63 /// Environment to set when executing either binaries or the command. 64 string[string] env; 65 66 bool captureAllOutput; 67 68 /// max bytes to save from a test case. 69 MaxCaptureBytes maxOutput = 10 * 1024 * 1024; 70 71 MinAvailableMemBytes minAvailableMem_; 72 } 73 74 static auto make(int poolSize) { 75 return TestRunner(poolSize); 76 } 77 78 this(int poolSize_) { 79 this.ownsPool = true; 80 this.poolSize(poolSize_); 81 this.earlyStopSignal = new Signal(false); 82 } 83 84 this(TaskPool pool, Duration timeout_, TestCmd[] commands, long nrOfRuns, 85 bool captureAllOutput, MaxCaptureBytes maxOutput, MinAvailableMemBytes minAvailableMem_) { 86 this.pool = pool; 87 this.timeout_ = timeout_; 88 this.earlyStopSignal = new Signal(false); 89 this.commands = commands; 90 this.nrOfRuns = nrOfRuns; 91 this.captureAllOutput = captureAllOutput; 92 this.maxOutput = maxOutput; 93 this.minAvailableMem_ = minAvailableMem_; 94 } 95 96 ~this() { 97 if (ownsPool) 98 pool.stop; 99 } 100 101 TestRunner dup() { 102 return TestRunner(pool, timeout_, commands, nrOfRuns, captureAllOutput, 103 maxOutput, minAvailableMem_); 104 } 105 106 string[string] getDefaultEnv() @safe pure nothrow @nogc { 107 return env; 108 } 109 110 void defaultEnv(string[string] env) @safe pure nothrow @nogc { 111 this.env = env; 112 } 113 114 void maxOutputCapture(MaxCaptureBytes bytes) @safe pure nothrow @nogc { 115 this.maxOutput = bytes; 116 } 117 118 void minAvailableMem(MinAvailableMemBytes bytes) @safe pure nothrow @nogc { 119 this.minAvailableMem_ = bytes; 120 } 121 122 /** Stop executing tests as soon as one detects a failure. 123 * 124 * This lose some information about the test cases but mean that mutation 125 * testing overall is executing faster. 126 */ 127 void useEarlyStop(bool v) @safe nothrow { 128 this.earlyStopSignal = new Signal(v); 129 } 130 131 void captureAll(bool v) @safe pure nothrow @nogc { 132 this.captureAllOutput = v; 133 } 134 135 bool empty() @safe pure nothrow const @nogc { 136 return commands.length == 0; 137 } 138 139 void poolSize(const int s) @safe { 140 if (pool !is null) { 141 pool.stop; 142 } 143 if (s == 0) { 144 pool = new TaskPool; 145 } else { 146 pool = new TaskPool(s); 147 } 148 pool.isDaemon = true; 149 } 150 151 void timeout(Duration timeout) pure nothrow @nogc { 152 this.timeout_ = timeout; 153 } 154 155 void put(ShellCommand sh) pure nothrow { 156 if (!sh.value.empty) 157 commands ~= TestCmd(sh, 0); 158 } 159 160 void put(ShellCommand[] shs) pure nothrow { 161 foreach (a; shs) 162 put(a); 163 } 164 165 TestCmd[] testCmds() @safe pure nothrow @nogc { 166 return commands; 167 } 168 169 TestResult run() { 170 return this.run(timeout_, null, SkipTests.init); 171 } 172 173 TestResult run(SkipTests skipTests) { 174 return this.run(timeout_, null, skipTests); 175 } 176 177 TestResult run(string[string] localEnv) { 178 return this.run(timeout_, localEnv, SkipTests.init); 179 } 180 181 TestResult run(Duration timeout, string[string] localEnv = null, 182 SkipTests skipTests = SkipTests.init) { 183 import core.thread : Thread; 184 import core.time : dur; 185 import std.range : enumerate; 186 187 static TestTask* findDone(ref TestTask*[] tasks) { 188 bool found; 189 size_t idx; 190 foreach (t; tasks.enumerate.filter!(a => a.value.done)) { 191 idx = t.index; 192 found = true; 193 break; 194 } 195 196 if (found) { 197 auto t = tasks[idx]; 198 tasks[idx] = tasks[$ - 1]; 199 tasks = tasks[0 .. $ - 1]; 200 return t; 201 } 202 return null; 203 } 204 205 void processDone(TestTask* t, ref TestResult result) { 206 auto res = t.yieldForce; 207 208 result.exitStatus = mergeExitStatus(result.exitStatus, res.exitStatus); 209 210 final switch (res.status) { 211 case RunResult.Status.normal: 212 if (result.status == TestResult.Status.passed && res.exitStatus.get != 0) { 213 result.status = TestResult.Status.failed; 214 } 215 if (res.exitStatus.get != 0) { 216 incrCmdKills(res.cmd); 217 result.output[res.cmd] = res.output; 218 } else if (captureAllOutput && res.exitStatus.get == 0) { 219 result.output[res.cmd] = res.output; 220 } 221 break; 222 case RunResult.Status.timeout: 223 result.status = TestResult.Status.timeout; 224 break; 225 case RunResult.Status.memOverload: 226 result.status = TestResult.Status.memOverload; 227 break; 228 case RunResult.Status.error: 229 result.status = TestResult.Status.error; 230 break; 231 } 232 } 233 234 auto env_ = env; 235 foreach (kv; localEnv.byKeyValue) { 236 env_[kv.key] = kv.value; 237 } 238 239 const reorderWhen = 10; 240 241 scope (exit) 242 nrOfRuns++; 243 if (nrOfRuns == 0) { 244 commands = commands.randomCover.array; 245 } else if (nrOfRuns % reorderWhen == 0) { 246 // use a forget factor to make the order re-arrange over time 247 // if the "best" test case change. 248 foreach (ref a; commands) { 249 a.kills = a.kills * 0.9; 250 } 251 252 import std.algorithm : sort; 253 254 // use those that kill the most first 255 commands = commands.sort!((a, b) => a.kills > b.kills).array; 256 logger.infof("Update test command order: %(%s, %)", 257 commands.take(reorderWhen).map!(a => format("%s:%.2f", a.cmd, a.kills))); 258 } 259 260 auto mtx = new Mutex; 261 auto condDone = new Condition(mtx); 262 earlyStopSignal.reset; 263 TestTask*[] tasks = startTests(timeout, env_, skipTests, mtx, condDone); 264 TestResult rval; 265 while (!tasks.empty) { 266 auto t = findDone(tasks); 267 if (t !is null) { 268 processDone(t, rval); 269 .destroy(t); 270 } else { 271 synchronized (mtx) { 272 () @trusted { condDone.wait(10.dur!"msecs"); }(); 273 } 274 } 275 } 276 277 return rval; 278 } 279 280 private auto startTests(Duration timeout, string[string] env, 281 SkipTests skipTests, Mutex mtx, Condition condDone) @trusted { 282 auto tasks = appender!(TestTask*[])(); 283 284 foreach (c; commands.filter!(a => a.cmd.value[0]!in skipTests.get)) { 285 auto t = task!spawnRunTest(c.cmd, timeout, env, maxOutput, 286 minAvailableMem_, earlyStopSignal, mtx, condDone); 287 tasks.put(t); 288 pool.put(t); 289 } 290 return tasks.data; 291 } 292 293 /// Find the test command and update its kill counter. 294 private void incrCmdKills(ShellCommand cmd) { 295 foreach (ref a; commands) { 296 if (a.cmd == cmd) { 297 a.kills++; 298 break; 299 } 300 } 301 } 302 } 303 304 alias SkipTests = NamedType!(Set!string, Tag!"SkipTests", Set!string.init, TagStringable); 305 306 /// The result of running the tests. 307 struct TestResult { 308 enum Status { 309 /// All test commands exited with exit status zero. 310 passed, 311 /// At least one test command indicated that an error where found by exit status not zero. 312 failed, 313 /// At least one test command timed out. 314 timeout, 315 /// memory overload 316 memOverload, 317 /// Something happend when the test command executed thus the result should not be used. 318 error 319 } 320 321 Status status; 322 ExitStatus exitStatus; 323 324 /// Output from all test binaries and command with exist status != 0. 325 DrainElement[][ShellCommand] output; 326 } 327 328 /// Finds all executables in a directory tree. 329 string[] findExecutables(AbsolutePath root, SpanMode mode = SpanMode.breadth) @trusted { 330 import core.sys.posix.sys.stat; 331 import std.file : getAttributes; 332 import std.file : dirEntries; 333 import my.file : isExecutable; 334 335 auto app = appender!(string[])(); 336 foreach (f; dirEntries(root, mode).filter!(a => a.isFile) 337 .filter!(a => isExecutable(Path(a.name)))) { 338 app.put([f.name]); 339 } 340 341 return app.data; 342 } 343 344 RunResult spawnRunTest(ShellCommand cmd, Duration timeout, string[string] env, TestRunner.MaxCaptureBytes maxOutputCapture, 345 TestRunner.MinAvailableMemBytes minAvailableMem, Signal earlyStop, 346 Mutex mtx, Condition condDone) @trusted nothrow { 347 import std.algorithm : copy; 348 static import std.process; 349 350 auto availMem = AvailableMem.make(); 351 scope (exit) 352 () { 353 try { 354 .destroy(availMem); 355 } catch (Exception e) { 356 } 357 }(); 358 bool isMemLimitTrigger() { 359 return availMem.available < minAvailableMem.get; 360 } 361 362 scope (exit) 363 () nothrow{ 364 try { 365 synchronized (mtx) { 366 condDone.notify; 367 } 368 } catch (Exception e) { 369 } 370 }(); 371 372 RunResult rval; 373 rval.cmd = cmd; 374 375 if (earlyStop.isActive) { 376 debug logger.tracef("Early stop detected. Skipping %s (%s)", cmd, 377 Clock.currTime).collectException; 378 return rval; 379 } 380 381 try { 382 auto p = pipeProcess(cmd.value, std.process.Redirect.all, env).sandbox.timeout(timeout) 383 .rcKill; 384 auto output = appender!(DrainElement[])(); 385 ulong outputBytes; 386 foreach (a; p.process.drain) { 387 if (!a.empty && (outputBytes + a.data.length) < maxOutputCapture.get) { 388 output.put(a); 389 outputBytes += a.data.length; 390 } 391 if (earlyStop.isActive) { 392 debug logger.tracef("Early stop detected. Stopping %s (%s)", cmd, Clock.currTime); 393 p.kill; 394 break; 395 } 396 if (isMemLimitTrigger) { 397 logger.infof("Available memory below limit. Stopping %s (%s < %s)", 398 cmd, availMem.available, minAvailableMem.get); 399 p.kill; 400 rval.status = RunResult.Status.memOverload; 401 break; 402 } 403 } 404 405 if (p.timeoutTriggered) { 406 rval.status = RunResult.Status.timeout; 407 } 408 409 rval.exitStatus = p.wait.ExitStatus; 410 rval.output = output.data; 411 } catch (Exception e) { 412 logger.warning(cmd).collectException; 413 logger.warning(e.msg).collectException; 414 rval.status = RunResult.Status.error; 415 } 416 417 if (rval.exitStatus.get != 0) { 418 earlyStop.activate; 419 debug logger.tracef("Early stop triggered by %s (%s)", rval.cmd, 420 Clock.currTime).collectException; 421 } 422 423 return rval; 424 } 425 426 private: 427 428 struct RunResult { 429 enum Status { 430 /// the test command successfully executed. 431 normal, 432 /// Something happend when the test command executed thus the result should not be used. 433 error, 434 /// The test command timed out. 435 timeout, 436 /// memory overload 437 memOverload 438 } 439 440 /// The command that where executed. 441 ShellCommand cmd; 442 443 Status status; 444 /// 445 ExitStatus exitStatus; 446 /// 447 DrainElement[] output; 448 } 449 450 string makeUnittestScript(string script, string file = __FILE__, uint line = __LINE__) { 451 import core.sys.posix.sys.stat; 452 import std.file : getAttributes, setAttributes, thisExePath; 453 import std.stdio : File; 454 import std.path : baseName; 455 import std.conv : to; 456 457 immutable fname = thisExePath ~ "_" ~ script ~ file.baseName ~ line.to!string ~ ".sh"; 458 459 File(fname, "w").writeln(`#!/bin/bash 460 echo $1 461 if [[ "$3" = "timeout" ]]; then 462 sleep 10m 463 fi 464 exit $2`); 465 setAttributes(fname, getAttributes(fname) | S_IXUSR | S_IXGRP | S_IXOTH); 466 return fname; 467 } 468 469 version (unittest) { 470 import core.time : dur; 471 import std.algorithm : count; 472 import std.array : array, empty; 473 import std.file : remove, exists; 474 import std.string : strip; 475 } 476 477 @("shall collect the result from the test commands when running them") 478 unittest { 479 immutable script = makeUnittestScript("script_"); 480 scope (exit) 481 () { 482 if (exists(script)) 483 remove(script); 484 }(); 485 486 auto runner = TestRunner.make(0); 487 runner.captureAll(true); 488 runner.put([script, "foo", "0"].ShellCommand); 489 runner.put([script, "foo", "0"].ShellCommand); 490 auto res = runner.run(5.dur!"seconds"); 491 492 res.output.byKey.count.shouldEqual(1); 493 res.output.byValue.filter!"!a.empty".count.shouldEqual(1); 494 res.output.byValue.joiner.filter!(a => a.byUTF8.array.strip == "foo").count.shouldEqual(1); 495 res.status.shouldEqual(TestResult.Status.passed); 496 } 497 498 @("shall set the status to failed when one of test command fail") 499 unittest { 500 immutable script = makeUnittestScript("script_"); 501 scope (exit) 502 () { 503 if (exists(script)) 504 remove(script); 505 }(); 506 507 auto runner = TestRunner.make(0); 508 runner.put([script, "foo", "0"].ShellCommand); 509 runner.put([script, "foo", "1"].ShellCommand); 510 auto res = runner.run(5.dur!"seconds"); 511 512 res.output.byKey.count.shouldEqual(1); 513 res.output.byValue.joiner.filter!(a => a.byUTF8.array.strip == "foo").count.shouldEqual(1); 514 res.status.shouldEqual(TestResult.Status.failed); 515 } 516 517 @("shall set the status to timeout when one of the tests commands reach the timeout limit") 518 unittest { 519 immutable script = makeUnittestScript("script_"); 520 scope (exit) 521 () { 522 if (exists(script)) 523 remove(script); 524 }(); 525 526 auto runner = TestRunner.make(0); 527 runner.put([script, "foo", "0"].ShellCommand); 528 runner.put([script, "foo", "0", "timeout"].ShellCommand); 529 auto res = runner.run(1.dur!"seconds"); 530 531 res.status.shouldEqual(TestResult.Status.timeout); 532 res.output.byKey.count.shouldEqual(0); // no output should be saved 533 } 534 535 @("shall only capture at most ") 536 unittest { 537 import std.algorithm : sum; 538 539 immutable script = makeUnittestScript("script_"); 540 scope (exit) 541 () { 542 if (exists(script)) 543 remove(script); 544 }(); 545 546 auto runner = TestRunner.make(0); 547 runner.put([script, "my_output", "1"].ShellCommand); 548 549 // capture up to default max. 550 auto res = runner.run(1.dur!"seconds"); 551 res.output.byValue.joiner.map!(a => a.data.length).sum.shouldEqual(10); 552 553 // reduce max and then nothing is captured because the minimum output is 9 byte 554 runner.maxOutputCapture(TestRunner.MaxCaptureBytes(4)); 555 res = runner.run(1.dur!"seconds"); 556 res.output.byValue.joiner.map!(a => a.data.length).sum.shouldEqual(0); 557 } 558 559 /// Thread safe signal. 560 class Signal { 561 import core.atomic : atomicLoad, atomicStore; 562 563 shared int state; 564 immutable bool isUsed; 565 566 this(bool isUsed) @safe pure nothrow @nogc { 567 this.isUsed = isUsed; 568 } 569 570 bool isActive() @safe nothrow @nogc const { 571 if (!isUsed) 572 return false; 573 574 auto local = atomicLoad(state); 575 return local != 0; 576 } 577 578 void activate() @safe nothrow @nogc { 579 atomicStore(state, 1); 580 } 581 582 void reset() @safe nothrow @nogc { 583 atomicStore(state, 0); 584 } 585 } 586 587 /// Merge the new exit code with the old one keeping the dominant. 588 ExitStatus mergeExitStatus(ExitStatus old, ExitStatus new_) { 589 import std.algorithm : max, min; 590 591 if (old.get == 0) 592 return new_; 593 594 if (old.get < 0) { 595 return min(old.get, new_.get).ExitStatus; 596 } 597 598 // a value 128+n is a value from the OS which is pretty bad such as a segmentation fault. 599 // those <128 are user created. 600 return max(old.get, new_.get).ExitStatus; 601 } 602 603 struct AvailableMem { 604 import std.conv : to; 605 import std.datetime : SysTime; 606 import std.stdio : File; 607 import std.string : startsWith, split; 608 609 static const Duration pollFreq = 5.dur!"seconds"; 610 File procMem; 611 SysTime nextPoll; 612 long current = long.max; 613 614 static AvailableMem* make() @safe nothrow { 615 try { 616 return new AvailableMem(File("/proc/meminfo"), Clock.currTime); 617 } catch (Exception e) { 618 logger.warning("Unable to open /proc/meminfo").collectException; 619 } 620 return new AvailableMem(File.init, Clock.currTime); 621 } 622 623 long available() @trusted nothrow { 624 if (Clock.currTime > nextPoll && procMem.isOpen) { 625 try { 626 procMem.rewind; 627 procMem.flush; 628 foreach (l; procMem.byLine 629 .filter!(l => l.startsWith("MemAvailable")) 630 .map!(a => a.split) 631 .filter!(a => a.length >= 3)) { 632 current = to!long(l[1]) * 1024; 633 break; 634 } 635 } catch (Exception e) { 636 current = long.max; 637 } 638 nextPoll = Clock.currTime + pollFreq; 639 } 640 641 return current; 642 } 643 }