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 std.algorithm : filter, map, joiner; 17 import std.array : appender, Appender, empty, array; 18 import std.datetime : Duration, dur, Clock; 19 import std.exception : collectException; 20 import std.format : format; 21 import std.parallelism : TaskPool, Task, task; 22 import std.random : randomCover; 23 import std.range : take; 24 import std.typecons : Tuple; 25 26 import proc; 27 28 import dextool.plugin.mutate.type : ShellCommand; 29 import dextool.type; 30 31 version (unittest) { 32 import unit_threaded.assertions; 33 } 34 35 @safe: 36 37 struct TestRunner { 38 private { 39 alias TestTask = Task!(spawnRunTest, string[], Duration, string[string], Signal); 40 TaskPool pool; 41 Duration timeout_; 42 43 Signal earlyStopSignal; 44 45 /// Commands that execute the test cases. 46 alias TestCmd = Tuple!(ShellCommand, "cmd", double, "kills"); 47 TestCmd[] commands; 48 long nrOfRuns; 49 } 50 51 /// Environment to set when executing either binaries or the command. 52 string[string] env; 53 54 static auto make(int poolSize) { 55 return TestRunner(poolSize); 56 } 57 58 this(int poolSize_) { 59 this.poolSize(poolSize_); 60 this.earlyStopSignal = new Signal(false); 61 } 62 63 ~this() { 64 pool.stop; 65 } 66 67 /** Stop executing tests as soon as one detects a failure. 68 * 69 * This lose some information about the test cases but mean that mutation 70 * testing overall is executing faster. 71 */ 72 void useEarlyStop(bool v) @safe nothrow { 73 this.earlyStopSignal = new Signal(v); 74 } 75 76 bool empty() @safe pure nothrow const @nogc { 77 return commands.length == 0; 78 } 79 80 void poolSize(const int s) @safe { 81 if (pool !is null) { 82 pool.stop; 83 } 84 if (s == 0) { 85 pool = new TaskPool; 86 } else { 87 pool = new TaskPool(s); 88 } 89 pool.isDaemon = true; 90 } 91 92 void timeout(Duration timeout) pure nothrow @nogc { 93 this.timeout_ = timeout; 94 } 95 96 void put(ShellCommand sh) pure nothrow { 97 commands ~= TestCmd(sh, 0); 98 } 99 100 void put(ShellCommand[] sh) pure nothrow { 101 foreach (a; sh) 102 commands ~= TestCmd(a, 0); 103 } 104 105 TestResult run(string[string] localEnv = null) { 106 return this.run(timeout_, localEnv); 107 } 108 109 TestResult run(Duration timeout, string[string] localEnv = null) { 110 import core.thread : Thread; 111 import core.time : dur; 112 import std.range : enumerate; 113 114 static TestTask* findDone(ref TestTask*[] tasks) { 115 bool found; 116 size_t idx; 117 foreach (t; tasks.enumerate.filter!(a => a.value.done)) { 118 idx = t.index; 119 found = true; 120 break; 121 } 122 123 if (found) { 124 auto t = tasks[idx]; 125 tasks[idx] = tasks[$ - 1]; 126 tasks = tasks[0 .. $ - 1]; 127 return t; 128 } 129 return null; 130 } 131 132 void processDone(TestTask* t, ref TestResult result, ref Appender!(DrainElement[]) output) { 133 auto res = t.yieldForce; 134 final switch (res.status) { 135 case RunResult.Status.normal: 136 if (result.status == TestResult.Status.passed && res.exitStatus != 0) { 137 result.status = TestResult.Status.failed; 138 } 139 if (res.exitStatus != 0) { 140 incrCmdKills(res.cmd); 141 } 142 output.put(res.output); 143 break; 144 case RunResult.Status.timeout: 145 result.status = TestResult.Status.timeout; 146 break; 147 case RunResult.Status.error: 148 result.status = TestResult.Status.error; 149 break; 150 } 151 } 152 153 auto env_ = env; 154 foreach (kv; localEnv.byKeyValue) { 155 env_[kv.key] = kv.value; 156 } 157 158 const reorderWhen = 10; 159 160 scope (exit) 161 nrOfRuns++; 162 if (nrOfRuns == 0) { 163 commands = commands.randomCover.array; 164 } else if (nrOfRuns % reorderWhen == 0) { 165 // use a forget factor to make the order re-arrange over time 166 // if the "best" test case change. 167 foreach (ref a; commands) { 168 a.kills = a.kills * 0.9; 169 } 170 171 import std.algorithm : sort; 172 173 // use those that kill the most first 174 commands = commands.sort!((a, b) => a.kills > b.kills).array; 175 logger.infof("Update test command order: %(%s, %)", 176 commands.take(reorderWhen).map!(a => format("%s:%.2f", a.cmd, a.kills))); 177 } 178 179 earlyStopSignal.reset; 180 TestTask*[] tasks = startTests(timeout, env_); 181 TestResult rval; 182 auto output = appender!(DrainElement[])(); 183 while (!tasks.empty) { 184 auto t = findDone(tasks); 185 if (t !is null) { 186 processDone(t, rval, output); 187 .destroy(t); 188 } 189 () @trusted { Thread.sleep(20.dur!"msecs"); }(); 190 } 191 192 rval.output = output.data; 193 return rval; 194 } 195 196 private auto startTests(Duration timeout, string[string] env) @trusted { 197 auto tasks = appender!(TestTask*[])(); 198 199 foreach (c; commands) { 200 auto t = task!spawnRunTest(c.cmd.value, timeout, env, earlyStopSignal); 201 tasks.put(t); 202 pool.put(t); 203 } 204 return tasks.data; 205 } 206 207 /// Find the test command and update its kill counter. 208 private void incrCmdKills(string[] cmd) { 209 foreach (ref a; commands) { 210 if (a.cmd.value == cmd) { 211 a.kills++; 212 break; 213 } 214 } 215 } 216 } 217 218 /// The result of running the tests. 219 struct TestResult { 220 enum Status { 221 /// All test commands exited with exit status zero. 222 passed, 223 /// At least one test command indicated that an error where found by exit status not zero. 224 failed, 225 /// At least one test command timed out. 226 timeout, 227 /// Something happend when the test command executed thus the result should not be used. 228 error, 229 } 230 231 Status status; 232 233 /// Output from all the test binaries and command. 234 DrainElement[] output; 235 } 236 237 /// Finds all executables in a directory tree. 238 string[] findExecutables(AbsolutePath root) @trusted { 239 import core.sys.posix.sys.stat; 240 import std.file : getAttributes; 241 import std.file : dirEntries, SpanMode; 242 243 static bool isExecutable(string p) nothrow { 244 try { 245 return (getAttributes(p) & S_IXUSR) != 0; 246 } catch (Exception e) { 247 } 248 return false; 249 } 250 251 auto app = appender!(string[])(); 252 foreach (f; dirEntries(root, SpanMode.breadth).filter!(a => a.isFile) 253 .filter!(a => isExecutable(a.name))) { 254 app.put([f.name]); 255 } 256 257 return app.data; 258 } 259 260 RunResult spawnRunTest(string[] cmd, Duration timeout, string[string] env, Signal earlyStop) @trusted nothrow { 261 import std.algorithm : copy; 262 static import std.process; 263 264 RunResult rval; 265 rval.cmd = cmd; 266 267 if (earlyStop.isActive) { 268 debug logger.tracef("Early stop detected. Skipping %s (%s)", cmd, 269 Clock.currTime).collectException; 270 return rval; 271 } 272 273 try { 274 auto p = pipeProcess(cmd, std.process.Redirect.all, env).sandbox.timeout(timeout).scopeKill; 275 auto output = appender!(DrainElement[])(); 276 foreach (a; p.process.drain(20.dur!"msecs")) { 277 if (!a.empty) 278 output.put(a); 279 if (earlyStop.isActive) { 280 debug logger.tracef("Early stop detected. Stopping %s (%s)", cmd, Clock.currTime); 281 p.kill; 282 break; 283 } 284 } 285 286 if (p.timeoutTriggered) { 287 rval.status = RunResult.Status.timeout; 288 } 289 290 rval.exitStatus = p.wait; 291 rval.output = output.data; 292 } catch (Exception e) { 293 logger.warning(e.msg).collectException; 294 rval.status = RunResult.Status.error; 295 } 296 297 if (rval.exitStatus != 0) { 298 earlyStop.activate; 299 debug logger.tracef("Early stop triggered by %s (%s)", rval.cmd, 300 Clock.currTime).collectException; 301 } 302 303 return rval; 304 } 305 306 private: 307 308 struct RunResult { 309 enum Status { 310 /// the test command successfully executed. 311 normal, 312 /// Something happend when the test command executed thus the result should not be used. 313 error, 314 /// The test command timed out. 315 timeout, 316 } 317 318 /// The command that where executed. 319 string[] cmd; 320 321 Status status; 322 /// 323 int exitStatus; 324 /// 325 DrainElement[] output; 326 } 327 328 string makeUnittestScript(string script, string file = __FILE__, uint line = __LINE__) { 329 import core.sys.posix.sys.stat; 330 import std.file : getAttributes, setAttributes, thisExePath; 331 import std.stdio : File; 332 import std.path : baseName; 333 import std.conv : to; 334 335 immutable fname = thisExePath ~ "_" ~ script ~ file.baseName ~ line.to!string ~ ".sh"; 336 337 File(fname, "w").writeln(`#!/bin/bash 338 echo $1 339 if [[ "$3" = "timeout" ]]; then 340 sleep 10m 341 fi 342 exit $2`); 343 setAttributes(fname, getAttributes(fname) | S_IXUSR | S_IXGRP | S_IXOTH); 344 return fname; 345 } 346 347 version (unittest) { 348 import core.time : dur; 349 import std.algorithm : count; 350 import std.array : array, empty; 351 import std.file : remove, exists; 352 import std.string : strip; 353 } 354 355 @("shall collect the result from the test commands when running them") 356 unittest { 357 immutable script = makeUnittestScript("script_"); 358 scope (exit) 359 () { 360 if (exists(script)) 361 remove(script); 362 }(); 363 364 auto runner = TestRunner.make(0); 365 runner.put([script, "foo", "0"].ShellCommand); 366 runner.put([script, "foo", "0"].ShellCommand); 367 auto res = runner.run(5.dur!"seconds"); 368 369 res.output.filter!(a => !a.empty).count.shouldEqual(2); 370 res.output.filter!(a => a.byUTF8.array.strip == "foo").count.shouldEqual(2); 371 res.status.shouldEqual(TestResult.Status.passed); 372 } 373 374 @("shall set the status to failed when one of test command fail") 375 unittest { 376 immutable script = makeUnittestScript("script_"); 377 scope (exit) 378 () { 379 if (exists(script)) 380 remove(script); 381 }(); 382 383 auto runner = TestRunner.make(0); 384 runner.put([script, "foo", "0"].ShellCommand); 385 runner.put([script, "foo", "1"].ShellCommand); 386 auto res = runner.run(5.dur!"seconds"); 387 388 res.output.filter!(a => !a.empty).count.shouldEqual(2); 389 res.output.filter!(a => a.byUTF8.array.strip == "foo").count.shouldEqual(2); 390 res.status.shouldEqual(TestResult.Status.failed); 391 } 392 393 @("shall set the status to timeout when one of the tests commands reach the timeout limit") 394 unittest { 395 immutable script = makeUnittestScript("script_"); 396 scope (exit) 397 () { 398 if (exists(script)) 399 remove(script); 400 }(); 401 402 auto runner = TestRunner.make(0); 403 runner.put([script, "foo", "0"].ShellCommand); 404 runner.put([script, "foo", "0", "timeout"].ShellCommand); 405 auto res = runner.run(1.dur!"seconds"); 406 407 res.output.filter!(a => a.byUTF8.array.strip == "foo").count.shouldEqual(1); 408 res.status.shouldEqual(TestResult.Status.timeout); 409 } 410 411 /// Thread safe signal. 412 class Signal { 413 import core.atomic : atomicLoad, atomicStore; 414 415 shared int state; 416 immutable bool isUsed; 417 418 this(bool isUsed) @safe pure nothrow @nogc { 419 this.isUsed = isUsed; 420 } 421 422 bool isActive() @safe nothrow @nogc const { 423 if (!isUsed) 424 return false; 425 426 auto local = atomicLoad(state); 427 return local != 0; 428 } 429 430 void activate() @safe nothrow @nogc { 431 atomicStore(state, 1); 432 } 433 434 void reset() @safe nothrow @nogc { 435 atomicStore(state, 0); 436 } 437 }