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