From 6e560f93ce27bdd27aa509061bde00c7da4c4db0 Mon Sep 17 00:00:00 2001 From: Lingfeng Fu <2871618714@qq.com> Date: Wed, 4 Oct 2023 14:26:14 +0800 Subject: [PATCH 1/5] lock required obsolete version --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From b3bf15b1815fdb026eae211b6ef270465bd3a3d5 Mon Sep 17 00:00:00 2001 From: Lingfeng Fu <2871618714@qq.com> Date: Wed, 4 Oct 2023 15:00:03 +0800 Subject: [PATCH 2/5] allow selecting mutators --- app/templates/v2_generate_patches.html | 2 +- app/utils/Mutation.py | 38 ++++++++++++++++---------- app/utils/SourceFile.py | 8 ++++-- app/views.py | 7 ++++- 4 files changed, 35 insertions(+), 20 deletions(-) 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/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/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..fc91e40 100644 --- a/app/views.py +++ b/app/views.py @@ -311,7 +311,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)) From 2e972781082e735819154f2d8ee5b53fb0fd0990 Mon Sep 17 00:00:00 2001 From: Lingfeng Fu <2871618714@qq.com> Date: Wed, 4 Oct 2023 14:26:14 +0800 Subject: [PATCH 3/5] ignore whitespace during patching --- app/utils/Executor.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/utils/Executor.py b/app/utils/Executor.py index 508ad55..425052b 100644 --- a/app/utils/Executor.py +++ b/app/utils/Executor.py @@ -86,7 +86,11 @@ def workflow(self, 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='/') + self.__execute_command_timeout( + 'patch --ignore-whitespace -p1 --input={patchfile} {inputfile}'.format( + patchfile=patchfile.name, inputfile=file.filename), + cwd='/' + ) # step 3: command pipeline success = self.__apply_command(patch, 'build_command') and \ @@ -100,8 +104,11 @@ def workflow(self, patch: Patch): 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='/') + self.__execute_command_timeout( + 'patch --ignore-whitespace -p1 --reverse --input={patchfile} {inputfile}'.format( + patchfile=patchfile.name, inputfile=file.filename), + cwd='/' + ) # step 6: delete patch file os.remove(patchfile.name) From 4c62c1337ea678f06230b481922712108da084e4 Mon Sep 17 00:00:00 2001 From: Lingfeng Fu <2871618714@qq.com> Date: Wed, 4 Oct 2023 20:13:12 +0800 Subject: [PATCH 4/5] multi-threading workflow --- app/utils/Executor.py | 232 +++++++++++++++++++++++++++++------------- 1 file changed, 160 insertions(+), 72 deletions(-) diff --git a/app/utils/Executor.py b/app/utils/Executor.py index 425052b..85843f4 100644 --- a/app/utils/Executor.py +++ b/app/utils/Executor.py @@ -1,25 +1,104 @@ # coding=utf-8 import shlex +import shutil import subprocess +import threading from threading import Timer, Thread +from concurrent.futures import ThreadPoolExecutor +from typing import Optional, Union import psutil -from app.models import Patch, Run, File +from app.models import Patch, Run, Project import tempfile import os import datetime from app import db +from pathlib import Path + + +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 + self.file_filename = patch.file.filename + self.project_id = patch.project_id + self.project = _ProjectRecord(patch.project) + self.patch = patch.patch + + +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 + + +class _ExecutionResult: + def __init__(self, patch_id: int): + self.patch_id: int = patch_id + self.run_records: list[_RunRecord] = [] + self.state: str = "incomplete" class Executor: 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 +109,76 @@ def stop(self): def count(self): return Patch.query.filter(Patch.state == 'incomplete').count() - @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) + with ThreadPoolExecutor(initializer=_thread_initializer) as executor: + patches = Patch.query.filter(Patch.state == 'incomplete').all() + patches = map(_PatchRecord, patches) + for result in executor.map(Executor.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 __execute_command_timeout(self, command, timeout=None, cwd=None, stdin=None): + @staticmethod + def workflow(patch: _PatchRecord) -> _ExecutionResult: + result = _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.__execute_command_timeout( + 'patch --ignore-whitespace -p1 --input={patchfile} {inputfile}'.format( + patchfile=patchfile.name, inputfile=file_path), + cwd='/' + ) + + # step 3: command pipeline + success = (Executor.__apply_command(result, patch, 'build_command') and + Executor.__apply_command(result, patch, 'quickcheck_command') and + Executor.__apply_command(result, patch, 'test_command')) + + Executor.__apply_command(result, patch, 'clean_command') + + if success: + result.state = 'survived' + else: + result.state = 'killed' + + # step 4: revert patch + Executor.__execute_command_timeout( + 'patch --ignore-whitespace -p1 --reverse --input={patchfile} {inputfile}'.format( + patchfile=patchfile.name, inputfile=file_path), + cwd='/' + ) + + # step 6: delete patch file + os.remove(patchfile.name) + + return result + + @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 +195,6 @@ def killer(process): finally: timer.cancel() - self.__processes.remove(proc.pid) - if cancelled: raise subprocess.TimeoutExpired(command, timeout, stdout) @@ -72,64 +203,23 @@ 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 --ignore-whitespace -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 --ignore-whitespace -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) + @staticmethod + def __apply_command(result: _ExecutionResult, patch: _PatchRecord, step: str) -> bool: + print(patch.id, step) + project = patch.project 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 @@ -137,7 +227,7 @@ def __apply_command(self, patch: Patch, step: str): if not command: return True - run = Run() + run = _RunRecord() run.command = step run.patch_id = patch.id run.project_id = patch.project_id @@ -145,7 +235,7 @@ def __apply_command(self, patch: Patch, step: str): # execute command try: - output = self.__execute_command_timeout(command, cwd=patch.project.workdir, timeout=timeout) + output = Executor.__execute_command_timeout(command, cwd=_thread_local.workspace.path, timeout=timeout) timeout = False success = True nochange = False @@ -178,11 +268,9 @@ def __apply_command(self, patch: Patch, step: str): run.log = log run.success = success - db.session.add(run) + result.run_records.append(run) if not success: - patch.state = 'killed' - - db.session.commit() + result.state = 'killed' return success From fa5ba7a2799926626fcfbb00a4caaba0c17fdc8f Mon Sep 17 00:00:00 2001 From: Lingfeng Fu <2871618714@qq.com> Date: Fri, 13 Oct 2023 13:58:32 +0800 Subject: [PATCH 5/5] optional multi-threading --- app/config.py | 2 + app/templates/v2_queue.html | 6 +- app/utils/Executor.py | 225 ++++++++++-------------------------- app/utils/ParExecutor.py | 149 ++++++++++++++++++++++++ app/utils/SeqExecutor.py | 92 +++++++++++++++ app/views.py | 11 +- 6 files changed, 314 insertions(+), 171 deletions(-) create mode 100644 app/utils/ParExecutor.py create mode 100644 app/utils/SeqExecutor.py 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_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 85843f4..ad88add 100644 --- a/app/utils/Executor.py +++ b/app/utils/Executor.py @@ -1,98 +1,15 @@ # coding=utf-8 import shlex -import shutil import subprocess -import threading from threading import Timer, Thread -from concurrent.futures import ThreadPoolExecutor -from typing import Optional, Union import psutil -from app.models import Patch, Run, Project -import tempfile -import os +from app.models import Patch, Run import datetime -from app import db -from pathlib import Path +from abc import ABC, abstractmethod -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 - self.file_filename = patch.file.filename - self.project_id = patch.project_id - self.project = _ProjectRecord(patch.project) - self.patch = patch.patch - - -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 - - -class _ExecutionResult: - def __init__(self, patch_id: int): - self.patch_id: int = patch_id - self.run_records: list[_RunRecord] = [] - self.state: str = "incomplete" - - -class Executor: +class Executor(ABC): def __init__(self, app): self.running = False self.app = app @@ -109,74 +26,16 @@ def stop(self): def count(self): return Patch.query.filter(Patch.state == 'incomplete').count() + @abstractmethod 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(Executor.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() - - @staticmethod - def workflow(patch: _PatchRecord) -> _ExecutionResult: - result = _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.__execute_command_timeout( - 'patch --ignore-whitespace -p1 --input={patchfile} {inputfile}'.format( - patchfile=patchfile.name, inputfile=file_path), - cwd='/' - ) - - # step 3: command pipeline - success = (Executor.__apply_command(result, patch, 'build_command') and - Executor.__apply_command(result, patch, 'quickcheck_command') and - Executor.__apply_command(result, patch, 'test_command')) + ... - Executor.__apply_command(result, patch, 'clean_command') - - if success: - result.state = 'survived' - else: - result.state = 'killed' - - # step 4: revert patch - Executor.__execute_command_timeout( - 'patch --ignore-whitespace -p1 --reverse --input={patchfile} {inputfile}'.format( - patchfile=patchfile.name, inputfile=file_path), - cwd='/' - ) - - # step 6: delete patch file - os.remove(patchfile.name) - - return result + @abstractmethod + def is_parallel(self): + ... @staticmethod - def __execute_command_timeout(command, timeout=None, cwd=None, stdin=None): + 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) @@ -203,11 +62,49 @@ def killer(process): return stdout + 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_command(result: _ExecutionResult, patch: _PatchRecord, step: str) -> bool: - print(patch.id, step) - project = patch.project + 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 = project.quickcheck_timeout command = project.quickcheck_command @@ -222,20 +119,19 @@ def __apply_command(result: _ExecutionResult, patch: _PatchRecord, step: str) -> 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 = _RunRecord() + @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 = Executor.__execute_command_timeout(command, cwd=_thread_local.workspace.path, timeout=timeout) + output = Executor._execute_command_timeout(command, cwd=cwd, timeout=timeout) timeout = False success = True nochange = False @@ -268,9 +164,4 @@ def __apply_command(result: _ExecutionResult, patch: _PatchRecord, step: str) -> run.log = log run.success = success - result.run_records.append(run) - - if not success: - result.state = 'killed' - - return success + return run 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/views.py b/app/views.py index fc91e40..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) ##############################################################################