diff --git a/app/config.py b/app/config.py index 659351e..5510266 100644 --- a/app/config.py +++ b/app/config.py @@ -13,3 +13,5 @@ ITEMS_PER_PAGE = 20 TEMPLATES_AUTO_RELOAD = True + +PARALLEL_WORKFLOW = True diff --git a/app/templates/v2_generate_patches.html b/app/templates/v2_generate_patches.html index 237e15f..a9e1859 100644 --- a/app/templates/v2_generate_patches.html +++ b/app/templates/v2_generate_patches.html @@ -31,7 +31,7 @@

Generate patches for file {{ file.filename|basename }}

{% for mutator_name, mutator in mutators.items() %}
diff --git a/app/templates/v2_queue.html b/app/templates/v2_queue.html index 3c697a5..40bf635 100644 --- a/app/templates/v2_queue.html +++ b/app/templates/v2_queue.html @@ -9,7 +9,11 @@ {% endblock %} {% block content %} -

Queue

+ {% if executor.is_parallel() %} +

Queue (Parallel)

+ {% else %} +

Queue (Sequential)

+ {% endif %}

{% if executor.running %} diff --git a/app/utils/Executor.py b/app/utils/Executor.py index 508ad55..ad88add 100644 --- a/app/utils/Executor.py +++ b/app/utils/Executor.py @@ -4,22 +4,18 @@ import subprocess from threading import Timer, Thread import psutil -from app.models import Patch, Run, File -import tempfile -import os +from app.models import Patch, Run import datetime -from app import db +from abc import ABC, abstractmethod -class Executor: +class Executor(ABC): def __init__(self, app): - self.__processes = [] self.running = False self.app = app - self.__current_patch = None def start(self): - if self.__current_patch is None: + if self.running is False: self.running = True Thread(target=self.main).start() @@ -30,22 +26,18 @@ def stop(self): def count(self): return Patch.query.filter(Patch.state == 'incomplete').count() - @property - def current_patch(self): - return self.__current_patch - + @abstractmethod def main(self): - with self.app.app_context(): - while self.running: - for patch in Patch.query.filter(Patch.state == 'incomplete').all(): - if self.running: - self.workflow(patch) - self.stop() - - def __execute_command_timeout(self, command, timeout=None, cwd=None, stdin=None): + ... + + @abstractmethod + def is_parallel(self): + ... + + @staticmethod + def _execute_command_timeout(command, timeout=None, cwd=None, stdin=None): proc = subprocess.Popen(shlex.split(command), stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd) - self.__processes.append(proc.pid) def killer(process): parent = psutil.Process(process.pid) @@ -62,8 +54,6 @@ def killer(process): finally: timer.cancel() - self.__processes.remove(proc.pid) - if cancelled: raise subprocess.TimeoutExpired(command, timeout, stdout) @@ -72,73 +62,76 @@ def killer(process): return stdout - def workflow(self, patch: Patch): - assert self.__current_patch is None, 'no auto-concurrency!' - - file = File.query.get(patch.file_id) - - if file is not None: - self.__current_patch = patch - - # step 1: write patch to temp file - patchfile = tempfile.NamedTemporaryFile(delete=False, mode='w') - patchfile.write(patch.patch) - patchfile.close() - - # step 2: apply patch - self.__execute_command_timeout('patch -p1 --input={patchfile} {inputfile}'.format(patchfile=patchfile.name, inputfile=file.filename), cwd='/') - - # step 3: command pipeline - success = self.__apply_command(patch, 'build_command') and \ - self.__apply_command(patch, 'quickcheck_command') and \ - self.__apply_command(patch, 'test_command') - - self.__apply_command(patch, 'clean_command') - - if success: - patch.state = 'survived' - db.session.commit() - - # step 4: revert patch - self.__execute_command_timeout('patch -p1 --reverse --input={patchfile} {inputfile}'.format(patchfile=patchfile.name, inputfile=file.filename), - cwd='/') - - # step 6: delete patch file - os.remove(patchfile.name) - - self.__current_patch = None - - def __apply_command(self, patch: Patch, step: str): - print(patch, step) - + class _RunRecord: + def __init__(self): + self.command = None + self.patch_id = None + self.project_id = None + self.timestamp_start = None + self.output = None + self.timestamp_end = None + self.duration = None + self.log = None + self.success = None + + def model(self): + m = Run() + m.command = self.command + m.patch_id = self.patch_id + m.project_id = self.project_id + m.timestamp_start = self.timestamp_start + m.output = self.output + m.timestamp_end = self.timestamp_end + m.duration = self.duration + m.log = self.log + m.success = self.success + return m + + @staticmethod + def _apply_patch(patch_file_path, input_file_path): + Executor._execute_command_timeout( + 'patch --ignore-whitespace -p1 --input={patchfile} {inputfile}'.format( + patchfile=patch_file_path, inputfile=input_file_path), + cwd='/' + ) + + @staticmethod + def _revert_patch(patch_file_path, input_file_path): + Executor._execute_command_timeout( + 'patch --ignore-whitespace -p1 --reverse --input={patchfile} {inputfile}'.format( + patchfile=patch_file_path, inputfile=input_file_path), + cwd='/' + ) + + @staticmethod + def _get_command_and_timeout(project, step): if step == 'quickcheck_command': - timeout = patch.project.quickcheck_timeout - command = patch.project.quickcheck_command + timeout = project.quickcheck_timeout + command = project.quickcheck_command elif step == 'test_command': - timeout = patch.project.test_timeout - command = patch.project.test_command + timeout = project.test_timeout + command = project.test_command elif step == 'build_command': timeout = None - command = patch.project.build_command + command = project.build_command elif step == 'clean_command': timeout = None - command = patch.project.clean_command + command = project.clean_command else: raise NotImplementedError + return command, timeout - # if no command is provided, return without creating a run; True means: next command must be executed - if not command: - return True - - run = Run() + @staticmethod + def _run_command(patch_id, project_id, step, command, cwd, timeout) -> _RunRecord: + run = Executor._RunRecord() run.command = step - run.patch_id = patch.id - run.project_id = patch.project_id + run.patch_id = patch_id + run.project_id = project_id run.timestamp_start = datetime.datetime.now() # execute command try: - output = self.__execute_command_timeout(command, cwd=patch.project.workdir, timeout=timeout) + output = Executor._execute_command_timeout(command, cwd=cwd, timeout=timeout) timeout = False success = True nochange = False @@ -171,11 +164,4 @@ def __apply_command(self, patch: Patch, step: str): run.log = log run.success = success - db.session.add(run) - - if not success: - patch.state = 'killed' - - db.session.commit() - - return success + return run diff --git a/app/utils/Mutation.py b/app/utils/Mutation.py index 621e97e..44fd648 100644 --- a/app/utils/Mutation.py +++ b/app/utils/Mutation.py @@ -51,7 +51,15 @@ def __repr__(self): ############################################################################## -class LineDeletionMutator: +class Mutator: + mutator_id: str + description: str + tags: list[str] + + def find_mutations(self, line: str) -> list[Replacement]: ... + + +class LineDeletionMutator(Mutator): mutator_id = 'lineDeletion' description = 'Deletes a whole line.' tags = ['naive'] @@ -67,7 +75,7 @@ def find_mutations(self, line): new_val=None)] -class LogicalOperatorMutator: +class LogicalOperatorMutator(Mutator): mutator_id = 'logicalOperator' description = 'Replaces logical operators.' tags = ['logical', 'operator'] @@ -86,7 +94,7 @@ def find_mutations(self, line): return self.pattern.mutate(line) -class ComparisonOperatorMutator: +class ComparisonOperatorMutator(Mutator): mutator_id = 'comparisonOperator' description = 'Replaces comparison operators.' tags = ['operator', 'comparison'] @@ -105,7 +113,7 @@ def find_mutations(self, line): return self.pattern.mutate(line) -class IncDecOperatorMutator: +class IncDecOperatorMutator(Mutator): mutator_id = 'incDecOperator' description = 'Swaps increment and decrement operators.' tags = ['operator', 'artithmetic'] @@ -120,7 +128,7 @@ def find_mutations(self, line): return self.pattern.mutate(line) -class AssignmentOperatorMutator: +class AssignmentOperatorMutator(Mutator): mutator_id = 'assignmentOperator' description = 'Replaces assignment operators.' tags = ['operator'] @@ -139,7 +147,7 @@ def find_mutations(self, line): return self.pattern.mutate(line) -class BooleanAssignmentOperatorMutator: +class BooleanAssignmentOperatorMutator(Mutator): mutator_id = 'booleanAssignmentOperator' description = 'Replaces Boolean assignment operators.' tags = ['operator', 'logical'] @@ -158,7 +166,7 @@ def find_mutations(self, line): return self.pattern.mutate(line) -class ArithmeticOperatorMutator: +class ArithmeticOperatorMutator(Mutator): mutator_id = 'arithmeticOperator' description = 'Replaces arithmetic operators.' tags = ['operator', 'artithmetic'] @@ -176,7 +184,7 @@ def find_mutations(self, line): return self.pattern.mutate(line) -class BooleanArithmeticOperatorMutator: +class BooleanArithmeticOperatorMutator(Mutator): mutator_id = 'booleanArithmeticOperator' description = 'Replaces Boolean arithmetic operators.' tags = ['operator', 'logical'] @@ -194,7 +202,7 @@ def find_mutations(self, line): return self.pattern.mutate(line) -class BooleanLiteralMutator: +class BooleanLiteralMutator(Mutator): mutator_id = 'booleanLiteral' description = 'Swaps the Boolean literals true and false.' tags = ['logical', 'literal'] @@ -209,7 +217,7 @@ def find_mutations(self, line): return self.pattern.mutate(line) -class StdInserterMutator: +class StdInserterMutator(Mutator): mutator_id = 'stdInserter' description = 'Changes the position where elements are inserted.' tags = ['stl'] @@ -224,7 +232,7 @@ def find_mutations(self, line): return self.pattern.mutate(line) -class StdRangePredicateMutator: +class StdRangePredicateMutator(Mutator): mutator_id = 'stdRangePredicate' description = 'Changes the semantics of an STL range predicate.' tags = ['stl'] @@ -240,7 +248,7 @@ def find_mutations(self, line): return self.pattern.mutate(line) -class StdMinMaxMutator: +class StdMinMaxMutator(Mutator): mutator_id = 'stdMinMax' description = 'Swaps STL minimum by maximum calls.' tags = ['stl', 'artithmetic'] @@ -255,7 +263,7 @@ def find_mutations(self, line): return self.pattern.mutate(line) -class DecimalNumberLiteralMutator: +class DecimalNumberLiteralMutator(Mutator): mutator_id = 'decimalNumberLiteral' description = 'Replaces decimal number literals with different values.' tags = ['numerical', 'literal'] @@ -290,7 +298,7 @@ def find_mutations(self, line): return result -class HexNumberLiteralMutator: +class HexNumberLiteralMutator(Mutator): mutator_id = 'hexNumberLiteral' description = 'Replaces hex number literals with different values.' tags = ['numerical', 'literal'] @@ -322,7 +330,7 @@ def find_mutations(self, line): return result -class IteratorRangeMutator: +class IteratorRangeMutator(Mutator): mutator_id = 'iteratorRange' description = 'Changes an iterator range.' tags = ['iterators'] diff --git a/app/utils/ParExecutor.py b/app/utils/ParExecutor.py new file mode 100644 index 0000000..f771f81 --- /dev/null +++ b/app/utils/ParExecutor.py @@ -0,0 +1,149 @@ +# coding=utf-8 + +import shutil +import threading +from concurrent.futures import ThreadPoolExecutor +from typing import Optional, Union +from app.models import Patch, Project +import tempfile +import os +from app import db +from pathlib import Path +from .Executor import Executor + + +class _RaiiTempDir: + def __init__(self, prefix="mutate_cpp"): + self.path = Path(tempfile.mkdtemp(prefix=prefix)) + + def __del__(self): + shutil.rmtree(self.path) + + +class _ThreadLocal: + workspace: _RaiiTempDir + last_project_id: Optional[int] + + +_thread_local: Union[_ThreadLocal, threading.local] = threading.local() + + +def _thread_initializer(): + _thread_local.workspace = _RaiiTempDir() + _thread_local.last_project_id = None + + +class _ProjectRecord: + def __init__(self, project: Project): + self.workdir = project.workdir + self.quickcheck_timeout = project.quickcheck_timeout + self.quickcheck_command = project.quickcheck_command + self.test_timeout = project.test_timeout + self.test_command = project.test_command + self.build_command = project.build_command + self.clean_command = project.clean_command + + +class _PatchRecord: + def __init__(self, patch: Patch): + self.id = patch.id + self.state = patch.state + self.file_id = patch.file_id + # noinspection PyUnresolvedReferences + self.file_filename = patch.file.filename + self.project_id = patch.project_id + # noinspection PyUnresolvedReferences + self.project = _ProjectRecord(patch.project) + self.patch = patch.patch + + +class ParExecutor(Executor): + def __init__(self, app): + super().__init__(app) + + def main(self): + with self.app.app_context(): + while self.running: + with ThreadPoolExecutor(initializer=_thread_initializer) as executor: + patches = Patch.query.filter(Patch.state == 'incomplete').all() + patches = map(_PatchRecord, patches) + for result in executor.map(ParExecutor.workflow, patches): + for run_record in result.run_records: + db.session.add(run_record.model()) + Patch.query.get(result.patch_id).state = result.state + db.session.commit() + if not self.running: + break + executor.shutdown(cancel_futures=True) + self.stop() + + def is_parallel(self): + return True + + class _ExecutionResult: + def __init__(self, patch_id: int): + self.patch_id: int = patch_id + self.run_records: list[Executor._RunRecord] = [] + self.state: str = "incomplete" + + @staticmethod + def workflow(patch: _PatchRecord) -> _ExecutionResult: + result = ParExecutor._ExecutionResult(patch.id) + if patch.file_filename is None: + return result + + # step 0: prepare workspace and file path + if patch.project_id != _thread_local.last_project_id: + shutil.rmtree(_thread_local.workspace.path) + shutil.copytree(patch.project.workdir, _thread_local.workspace.path) + _thread_local.last_project_id = patch.project_id + relative_path = Path(patch.file_filename).relative_to(Path(patch.project.workdir)) + file_path = _thread_local.workspace.path / relative_path + + # step 1: write patch to temp file + patchfile = tempfile.NamedTemporaryFile(delete=False, mode='w') + patchfile.write(patch.patch) + patchfile.close() + + # step 2: apply patch + Executor._apply_patch(patchfile.name, file_path) + + # step 3: command pipeline + success = (ParExecutor.__apply_command(result, patch, 'build_command') and + ParExecutor.__apply_command(result, patch, 'quickcheck_command') and + ParExecutor.__apply_command(result, patch, 'test_command')) + + ParExecutor.__apply_command(result, patch, 'clean_command') + + if success: + result.state = 'survived' + else: + result.state = 'killed' + + # step 4: revert patch + Executor._revert_patch(patchfile.name, file_path) + + # step 6: delete patch file + os.remove(patchfile.name) + + return result + + @staticmethod + def __apply_command(result: _ExecutionResult, patch: _PatchRecord, step: str) -> bool: + print(patch.id, step) + project = patch.project + + command, timeout = Executor._get_command_and_timeout(project, step) + + # if no command is provided, return without creating a run; True means: next command must be executed + if not command: + return True + + run = Executor._run_command(patch.id, patch.project_id, step, command, _thread_local.workspace.path, timeout) + + result.run_records.append(run) + + if not run.success: + result.state = 'killed' + + return run.success diff --git a/app/utils/SeqExecutor.py b/app/utils/SeqExecutor.py new file mode 100644 index 0000000..4e02933 --- /dev/null +++ b/app/utils/SeqExecutor.py @@ -0,0 +1,92 @@ +# coding=utf-8 + +from threading import Thread +from app.models import Patch, Project, File +import tempfile +import os +from app import db +from .Executor import Executor + + +class SeqExecutor(Executor): + def __init__(self, app): + super().__init__(app) + self.__current_patch = None + + def start(self): + if self.__current_patch is None: + self.running = True + Thread(target=self.main).start() + + @property + def current_patch(self): + return self.__current_patch + + def main(self): + with self.app.app_context(): + while self.running: + for patch in Patch.query.filter(Patch.state == 'incomplete').all(): + if self.running: + self.workflow(patch) + self.stop() + + def is_parallel(self): + return False + + def workflow(self, patch: Patch): + assert self.__current_patch is None, 'no auto-concurrency!' + + file: File = File.query.get(patch.file_id) + + if file is not None: + self.__current_patch = patch + + # step 1: write patch to temp file + patchfile = tempfile.NamedTemporaryFile(delete=False, mode='w') + patchfile.write(patch.patch) + patchfile.close() + + # step 2: apply patch + Executor._apply_patch(patchfile.name, file.filename) + + # step 3: command pipeline + success = (SeqExecutor.__apply_command(patch, 'build_command') and + SeqExecutor.__apply_command(patch, 'quickcheck_command') and + SeqExecutor.__apply_command(patch, 'test_command')) + + SeqExecutor.__apply_command(patch, 'clean_command') + + if success: + patch.state = 'survived' + db.session.commit() + + # step 4: revert patch + Executor._revert_patch(patchfile.name, file.filename) + + # step 6: delete patch file + os.remove(patchfile.name) + + self.__current_patch = None + + @staticmethod + def __apply_command(patch: Patch, step: str): + print(patch, step) + # noinspection PyUnresolvedReferences + project: Project = patch.project + + command, timeout = Executor._get_command_and_timeout(project, step) + + # if no command is provided, return without creating a run; True means: next command must be executed + if not command: + return True + + run = Executor._run_command(patch.id, patch.project_id, step, command, project.workdir, timeout) + + db.session.add(run.model()) + + if not run.success: + patch.state = 'killed' + + db.session.commit() + + return run.success diff --git a/app/utils/SourceFile.py b/app/utils/SourceFile.py index 917ee4e..e7c5a9b 100644 --- a/app/utils/SourceFile.py +++ b/app/utils/SourceFile.py @@ -3,7 +3,8 @@ from app import db import os from datetime import datetime -from app.utils.Mutation import get_mutators +from typing import Optional +from app.utils.Mutation import get_mutators, Mutator from app.utils.Replacement import Replacement from app.models import File, Patch @@ -25,8 +26,9 @@ def __init__(self, file: File, first_line, last_line): # read the relevant content self.content = '\n'.join(self.full_content[self.first_line - 1:self.last_line]) # type: str - def generate_patches(self): - mutators = get_mutators() + def generate_patches(self, mutators: Optional[dict[str, Mutator]] = None): + if mutators is None: + mutators = get_mutators() for line_number, line_raw in self.__get_lines(): for mutator_name, mutator in mutators.items(): diff --git a/app/views.py b/app/views.py index be1135f..6b1dd90 100644 --- a/app/views.py +++ b/app/views.py @@ -1,5 +1,5 @@ # coding=utf-8 - +from typing import Optional from flask import render_template, abort, redirect, url_for, flash, request from app import app, db from app.forms import CreateProjectForm, CreateFileForm, SetConfirmationForm @@ -9,14 +9,19 @@ from app.utils.Statistics import Statistics import os from app.utils.Executor import Executor +from app.utils.ParExecutor import ParExecutor +from app.utils.SeqExecutor import SeqExecutor -executor = None +executor: Optional[Executor] = None @app.before_first_request def init_executor(): global executor - executor = Executor(app) + if app.config['PARALLEL_WORKFLOW']: + executor = ParExecutor(app) + else: + executor = SeqExecutor(app) ############################################################################## @@ -311,7 +316,12 @@ def route_v2_project_project_id_files_file_id_generate(project_id, file_id): last_line = -1 s = SourceFile(file, first_line, last_line) - s.generate_patches() + selected_mutators = {} + all_mutators = get_mutators() + for mutator_id in all_mutators: + if mutator_id in request.form: + selected_mutators[mutator_id] = all_mutators[mutator_id] + s.generate_patches(selected_mutators) flash('Successfully created patches.', category='message') return redirect(url_for('route_v2_project_project_id', project_id=project.id)) diff --git a/requirements.txt b/requirements.txt index 2b09083..dadd9b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ appdirs>=1.4.4 Brotli>=1.0.9 click>=8.0.3 decorator>=5.1.1 -Flask>=2.0.2 +Flask==2.0.2 Flask-Compress>=1.10.1 Flask-Humanize>=0.3.0 Flask-SQLAlchemy>=2.5.1 @@ -17,9 +17,9 @@ psutil>=5.9.0 pyparsing>=3.0.7 python-utils>=3.1.0 six>=1.16.0 -SQLAlchemy>=1.4.31 +SQLAlchemy==1.4.31 sqlalchemy-migrate>=0.13.0 sqlparse>=0.4.2 Tempita>=0.5.2 -Werkzeug>=2.0.3 +Werkzeug==2.0.3 WTForms>=3.0.1