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