RCE Endeavors 😅

May 18, 2021

Creating a multi-language compiler system: Execution Engine (6/11)

Filed under: Programming — admin @ 10:29 PM

Table of Contents:

This post will cover the execution component of the multi-language compiler. If you’re still reading, then congratulations! This is the last component of the system and a not too complicated one at that. The execution component has two tasks: provide input (if necessary) and capture output. The approach taken here will be to connect a pseudoterminal to the executing process so that input/output can easily be captured by reading or writing to those streams.

The execution component splits these tasks up into individual threads:

  • Main thread: connect the pseudoterminal, execute the code, and wait for the timeout value to hit or for the process to exit
  • Reader thread: select on the pseudoterminal file descriptor and write the output to the output file.
  • Writer thread: monitor the stdin input file via inotify. On a change, read that change and write it to the stdin of the pseudoterminal file descriptor.

Main thread

The code to connect the pseudoterminal, launch the chilld process, and wait for its return is pretty straightforward. These are all done with provided system APIs, namely forkpty, execv, and waitpid. The snippet to accomplish this is shown below:

    ....
    int masterFileDescriptor = -1;
    pid_t childProcessId = forkpty(&masterFileDescriptor, NULL, NULL, NULL);
    int childReturnCode = -1;

    if (childProcessId == -1)
    {
        perror("forkpty");
        exit(-1);
    }

    if (childProcessId == 0)
    {
        int result = execv(options.BinaryToExecute.c_str(), options.BinaryArguments.data());
        exit(result);
    }
    else
    {
        std::thread(ListenForOutput, masterFileDescriptor, options.OutputFilePath)
            .detach();

        if (options.IsInteractive)
        {
            std::thread(ListenForInput, masterFileDescriptor, options.InputFilePath)
                .detach();
        }

        childReturnCode = WaitForCloseOrTimeout(childProcessId, options.MaxWaitTimeMs);
    }

    return childReturnCode;
}

What is shown in the code is exactly what was described above: the execution component creates a new process with a pseudoterminal attached. This process gets passed any command line arguments to it, and then the thread that listens for output is launched. If this is an interactive session (user can provide stdin input at runtime) then the thread that listens for input is launched as well. The process then runs and returns its return code to the execution component, which subsequently returns it to the script that invoked it.

The WaitForCloseOrTimeout function is just a wrapper around waitpid that polls the child exit code up to a maximum timeout value. If the timeout has been hit then the child process is killed and 124 is returned as the timeout exit code; otherwise if the process exits within the allotted time then its exit code is returned. The WaitForCloseOrTimeout function is shown below:


pid_t WaitForCloseOrTimeout(const pid_t childProcessId, const int maxWaitTimeMs)
{
    int childReturnCode = -1;

    constexpr int sleepTimeMicroseconds = 100000;
    int elapsedTimeMs = 0;
    bool timeoutExpired = false;
    bool childExited = false;
    while (!timeoutExpired && !childExited)
    {
        int result = waitpid(childProcessId, &childReturnCode, WNOHANG);
        if (result == -1)
        {
            perror("waitpid");
        }
        if (result == childProcessId)
        {
            childExited = true;
        }

        usleep(sleepTimeMicroseconds);
        elapsedTimeMs += sleepTimeMicroseconds / 1000;
        timeoutExpired = (elapsedTimeMs >= maxWaitTimeMs);
    }

    if (timeoutExpired)
    {
        constexpr int timeoutReturnCode = 124;
        childReturnCode = timeoutReturnCode;
        kill(-childProcessId, SIGTERM);
    }

    return childReturnCode;
}

Reader thread

The reader thread is as straightforward as can be: in a loop we read the output and write it to a file.

void ListenForOutput(const int masterFileDescriptor, const std::string outputFilePath)
{
    g_outputFile.rdbuf()->pubsetbuf(0, 0);
    g_outputFile.open(outputFilePath, std::ios::out);

    constexpr int BUFFERSIZE = 1024;
    std::array<char, BUFFERSIZE> buffer;
    fd_set fileDescriptors = { 0 };

    while (true)
    {
        FD_ZERO(&fileDescriptors);
        FD_SET(masterFileDescriptor, &fileDescriptors);

        if (select(masterFileDescriptor + 1, &fileDescriptors, NULL, NULL, NULL) > 0)
        {
            auto bytesRead = read(masterFileDescriptor, buffer.data(), buffer.size());
            if (bytesRead > 0)
            {
                g_outputFile.write(buffer.data(), bytesRead);
            }
        }
    }
}

Writer thread

The writer thread is a bit more complex. This thread needs to monitor the stdin file that contains the state of the interactive session. When writes are performed to this session file, the thread will need to read where the write occurred and write it to the stdin of the execution process. Since writes can happen multiple times to the session file, the last written offset must be kept track of. The full logic then is:

  • Add an inotify watch on the interactive session file for IN_CLOSE_WRITE events
  • On a close write event, read the file from the last offset to the end of file
  • Write this read data to the stdin of the executing process

The code snippet to accomplish this is shown below:

    ...
    if (pEvent->mask & IN_CLOSE_WRITE)
    {
        std::string fileName = pEvent->name;

        if (fileName == stdinFileName)
        {
            std::ifstream file(inputFileFullPath, std::ios::in | std::ios::binary);

            std::vector<char> contents;
            file.seekg(lastReadOffset, std::ios::end);
            contents.reserve(file.tellg());
            file.seekg(lastReadOffset, std::ios::beg);

            contents.assign((std::istreambuf_iterator<char>(file)),
                std::istreambuf_iterator<char>());

            lastReadOffset += contents.size();

            size_t bytesWritten = 0;
            do
            {
                ssize_t written = write(masterFileDescriptor, contents.data() + bytesWritten, contents.size() - bytesWritten);
                if (written == -1)
                {
                    perror("write");
                    break;
                }
                    bytesWritten += written;
                    } while (bytesWritten < contents.size());
                }
            }
    ...

And that’s all there is to it. At this point the entire system has been described end-to-end: from the time the user adds a source file to the input folder to how they get a response back for their executed program. The next series of posts will cover how to refine a system a bit further from a deployment perspective; namely how to containerize the code using Docker and how to provide some resiliency using Kubernetes.

Creating a multi-language compiler system: File Watcher, Bash (5/11)

Filed under: Programming — admin @ 10:29 PM

Table of Contents:

The previous post covered the first half of the file watcher component: the background C++ process responsible for monitoring the input file directory and starting the compilation and execution process. This next part will cover the helper Bash script that actually does the work. This script is composed of two parts: the main script, which is a general script responsible creating and cleaning up the directories that will be used, and language-specific scripts responsible for running the process of compilation and execution.

Main script

The main script is what is invoked by the background process. The path and name to this script is configured via the configuration file:

"bootstrappath": "${CODE_PATH}/share/bootstrap",
"bootstrapscriptname": "bootstrap-common.sh",
...

The background process builds the command to invoke this script (see NotifyChildProcess::buildCommand in the previous post), and captures its output. The main script is shown in its entirety below:

#!/bin/bash

POSITIONAL=()
while [[ $# -gt 0 ]]
do
key="$1"

case $key in
    -f|--filepath)
    FILE_PATH="$2"
    shift
    shift
    ;;
    -a|--arguments)
    ARGUMENTS_PATH="$2"
    shift
    shift
    ;;
    -i|--index)
    INDEX="$2"
    shift
    shift
    ;;
    -d|--dependenciespath)
    DEPENDENCIES_PATH="$2"
    shift
    shift
    ;;
    -w|--workspacepath)
    WORKSPACE_PATH="$2"
    shift
    shift
    ;;
    -o|--outputpath)
    OUTPUT_PATH="$2"
    shift
    shift
    ;;
    -s|--stdinPath)
    STDIN_PATH="$2"
    shift
    shift
    ;;
    -t|--timeout)
    INTERACTIVE_TIMEOUT="$2"
    shift
    shift
    ;;
    -l|--language)
    LANGUAGE="$2"
    shift
    shift
    ;;
    *)
    
    POSITIONAL+=("$1")
    shift
    ;;
esac
done
set -- "${POSITIONAL[@]}"

FILE_NAME="$(basename ${FILE_PATH})"
ARGS_FILE_EXISTS=false
STDIN_FILE_EXISTS=false

OUTPUT_NAME=${WORKSPACE_PATH}/${INDEX}/${FILE_NAME}

if [ -f "${ARGUMENTS_PATH}" ]; then
    ARGS_FILE_EXISTS=true
fi

if [ -f "${STDIN_PATH}" ]; then
    STDIN_FILE_EXISTS=true
fi

create_directories () {
    rm -rf ${WORKSPACE_PATH}/${INDEX}
    mkdir ${WORKSPACE_PATH}/${INDEX}
}

cleanup () {
    rm ${FILE_PATH}
    if [ "${ARGS_FILE_EXISTS}" = "true" ]; then
        rm ${ARGUMENTS_PATH}
    fi
    if [ "${STDIN_FILE_EXISTS}" = "true" ]; then
        rm ${STDIN_PATH}
    fi
    rm -rf ${WORKSPACE_PATH}/${INDEX}
}

copy_dependencies () {
    cp ${FILE_PATH} ${WORKSPACE_PATH}/${INDEX}
    cp -r ${DEPENDENCIES_PATH}/. ${WORKSPACE_PATH}/${INDEX}
}

move_output () {
    rm -rf ${OUTPUT_PATH}/${INDEX}
    mkdir ${OUTPUT_PATH}/${INDEX}
    mv ${OUTPUT_NAME}-output.log ${OUTPUT_PATH}/${INDEX}
}

main () {
    create_directories
    copy_dependencies
    (cd ${CODE_PATH}/share/${LANGUAGE}/bootstrap/${LANGUAGE}; source ./bootstrap.sh; run_command)
    move_output
    cleanup
    
    exit ${result}
}

main

Despite the large size of the script, half of it is just related to argument parsing and storage. The other half is for the actual functionality: creating the isolated workspace directory that the compilation and execution will take place in (create_directories function), copying the dependencies to this workspace directory (copy_dependencies function), moving the output to the output directory and cleaning up (move_output and cleanup functions). The run_command function is the language-specific function that will get invoked from its respective language directory.

Language-specific scripts

Each language has its own way to go from source code to executable. Compiled languages require a compilation step to generate an executable, while interpreted ones like Python can have the interpreter run directly on the source code. As a result, each language has its own language-specific script responsible for implementing this functionality.

For example, the run_command implementation for C files looks like this:

#!/bin/bash

run_command () {

    TIMEOUT_SECONDS_COMPILE=15s
    TIMEOUT_SECONDS_RUN=10s

    timeout ${TIMEOUT_SECONDS_COMPILE} gcc -Wall -std=c17 -Wno-deprecated ${OUTPUT_NAME} -o ${OUTPUT_NAME}.out >> ${OUTPUT_NAME}-output.log 2>&1
    result=$?
    
    if [ $result -eq 0 ]
    then
        chmod 753 ${OUTPUT_NAME}-output.log
        if [ "${ARGS_FILE_EXISTS}" = "true" ]; then
            ARGUMENTS=$(cat ${ARGUMENTS_PATH})
        fi
        if [ "${STDIN_FILE_EXISTS}" = "true" ]; then
            TIMEOUT_SECONDS_RUN=${INTERACTIVE_TIMEOUT}
            STDIN_ARGUMENTS="-s ${STDIN_PATH}"
        fi
        
        sudo su-exec exec ${EXEC_PATH}/executor -t ${TIMEOUT_SECONDS_RUN} ${STDIN_ARGUMENTS} -o ${OUTPUT_NAME}-output.log -f ${OUTPUT_NAME}.out ${ARGUMENTS}
        result=$?
    fi
}

This function begins by invoking the GCC compiler to create an executable. The output of this process is captured via stream redirection into an output file, so if the compilation fails then the cause will be still be captured and presented to the user. Once the executable is generated, the execution component is called to run it. Both of these steps come with a timeout value in order to prevent issues with hanging compiler processes or infinitely running executables.

Once the execution component has completed, the process return code is returned to the main script and subsequently the background process. The implementation of the execution component will be covered in-depth in the next post. After the discussion of the execution component, the end-to-end details of how the system works will be complete.

Creating a multi-language compiler system: File Watcher, C++ (4/11)

Filed under: Programming — admin @ 10:28 PM

Table of Contents:

These next few posts will go in to detail about the file watcher component. As previously discussed, this component is responsible for watching changes in the input directory and kick starting the compilation and execution process. The file watcher will be implemented as a background process, written in C++, along with some helper Bash scripts.

Configuration

The runtime configuration for the file watcher will be a straightforward JSON file. The deserialization functionality will come courtesy of the cereal library. In this configuration will be everything that is needed for the file watch process to perform its functionality: the paths to the various input, output, and intermediate directories and files, the set of supported languages, and whether to run in single threaded or multi-threaded mode. The final configuration is shown below. From looking at it, you can see that there are several variables that are meant to be substituted. How and why this is done will be covered in a future post, and a brief explanation of each field is provided below.

{
     "configuration": {
         "inputpath": "${CODE_PATH}/share/${LANGUAGE}/input/${UNIQUE_ID}",
         "outputpath": "${CODE_PATH}/share/${LANGUAGE}/output/${UNIQUE_ID}",
         "workspacepath": "${CODE_PATH}/share/${LANGUAGE}/workspace/${UNIQUE_ID}",
         "dependenciespath": "${CODE_PATH}/share/${LANGUAGE}/dependencies",
         "argumentspath": "${CODE_PATH}/share/${LANGUAGE}/arguments/${UNIQUE_ID}",
         "stdinpath": "${CODE_PATH}/share/${LANGUAGE}/stdin/${UNIQUE_ID}",
         "interactivetimeout": "600s",
         "relocatepath": "${CODE_PATH}/share/relocate/${UNIQUE_ID}",
         "bootstrappath": "${CODE_PATH}/share/bootstrap",
         "bootstrapscriptname": "bootstrap-common.sh",
         "supportedlanguages": [${SUPPORTED_LANGUAGES}],
         "ismultithreaded": ${IS_MULTITHREADED}
     }
 }

Hopefully most of these are pretty straightforward from their naming, or from having read the previous posts outlining the general architecture of the system. The inputpath is the folder where the user will provide the source code file to compile, and correspondingly the outputpath is where the execution output will be written to. The workspacepath is the folder where the compilation and execution will take place. As mentioned previously, this is to allow for multiple compilation processes at the same time without fear of one interfering with another.

The dependenciespath has not been discussed yet and is the path where dependencies for each language are present. What is meant by this is that more than just a source file is needed to compile under some languages. Specifically, to support compiling C# on the command line under .NET Core, there must be a .csproj file present in the directory. When the compilation process is to begin, everything present in the dependenciespath for a language is copied to the workspace path. With the languages supported under this particular system this is only an issue for C# and all others have an empty dependenciespath.

The argumentspath, stdinpath, and interactivetimeout all have to do with providing command-line input to a running executable. The argumentspath is the folder where the command-line arguments are stored for the source code. Likewise, the stdinpath is the folder where the input for the interactive session is stored. This file can change during the execution of a program and its changes will be picked up and written to the stdin of the running process by the execution component. The interactivetimeout is the time limit that this interactive session, where a user can provide input to a running process, can have.

For resiliency, there is a relocatepath field, which is the directory that input files that have not been processed yet will go in the event of a crash. This is done so that they may be relocated to another instance that is active for processing. The next two fields, bootstrappath and bootstrapscriptname are for the Bash scripts that are responsible for performing the core functionality: settings up the workspace folders, compiling the code, and invoking the execution component to run the executable and capture the output. The implementation of these Bash scripts will be covered in the next post.

Lastly, there are two, hopefully self-explanatory, fields supportedlanguages and ismultithreaded, which contain a list of supported languages for the compiler system and whether to run in multi-threaded mode.

This configuration file has a corresponding object in the file watcher code. The NotifyConfiguration object is defined below:

struct NotifyConfiguration
{
    std::string m_inputPath;
    std::string m_outputPath;
    std::string m_workspacePath;
    std::string m_dependenciesPath;
    std::string m_argumentsPath;
    std::string m_stdinPath;
    std::string m_interactiveTimeout;
    std::string m_relocatePath;
    std::string m_bootstrapPath;
    std::string m_bootstrapScriptName;
    std::vector<std::string> m_supportedLanguages;
    bool m_isMultithreaded;
};

The code to read this file at runtime is pretty straightforward thanks to the ease of the cereal API:

static std::shared_ptr<T> Read(const std::string& filePath, const std::string& name)
{
    std::ifstream fileStream(filePath);
    if (!fileStream.is_open())
    {
        std::cerr << "Failed to open " << filePath << std::endl;
        return nullptr;
    }

    T object;
    {
        cereal::JSONInputArchive iarchive(fileStream);
        iarchive(cereal::make_nvp(name.c_str(), object));
    }
  
    return std::make_shared<T>(object);
}

Event handlers

Listening to directory change events is what powers the entire compilation and execution process. The previous post covered the inotify API and this implementation just expands on that for a bit. Since multiple languages are supported, there needs to be a mapping between a language and an input folder:

std::unordered_map<std::string /*Language*/, std::unique_ptr<NotifyEventHandler> /*Handler*/> m_dispatchTable;

At runtime, each supported language will register a handler and add it to the map.

bool NotifyEventHandlerTopmost::AddHandler(std::unique_ptr<NotifyEventHandler> handler)
{
	const std::string& language = handler->Language();
	if (m_dispatchTable.find(handler->Language()) != m_dispatchTable.end())
	{
		std::cerr << "Handler for " << language << " already exists." << std::endl;
		return false;
	}

	std::cout << "Adding handler for " << language << std::endl;
	m_dispatchTable.insert({ handler->Language(), std::move(handler) });
	return true;
}

When an input file is added, its handler is found in the map, and if it exists, that handler subsequently gets invoked:

void NotifyEventHandlerTopmost::Handle(std::shared_ptr<NotifyEvent> event)
{
    const std::string& type = event->Language();
    auto handler = m_dispatchTable.find(type);
    if (handler == m_dispatchTable.end())
    {
        std::cerr << "No handler found for " << event->Language() << " files" << std::endl;
        return;
    }

    handler->second->Handle(event);
 }

Under this current implementation, all languages share a common handler, which launches a child process to compile and execute the source code, and subsequently reads the captured output.

void NotifyEventHandlerLanguage::Handle(std::shared_ptr<NotifyEvent> event)
{
    NotifyChildProcess childProcess(event);
    childProcess.Launch();

    auto outputPath = event->OutputPath() + std::filesystem::path::preferred_separator + std::to_string(event->Index()) +
		std::filesystem::path::preferred_separator + event->FileName() + ".log";
    const NotifyExecutionResult resultObject{ childProcess.Result(), childProcess.Output() };
    NotifySerializer<NotifyExecutionResult>::Write(outputPath, "result", resultObject);
}

Child process

The child process code is rather minimal. Since most of the workflow is delegated to a Bash script, the only things that the code needs to do is to format the arguments, call the Bash script, and read the output. The Bash script takes in a fair number of arguments whose usage and purpose will be explained in the next post.


void NotifyChildProcess::buildCommand(std::shared_ptr<NotifyEvent> notifyEvent)
{
	const auto argumentsFullPath = notifyEvent->ArgumentsPath() + std::filesystem::path::preferred_separator + notifyEvent->FileName() + ".args";
	const auto inputFileFullPath = notifyEvent->InputPath() + std::filesystem::path::preferred_separator + notifyEvent->FileName();
	const auto stdinFileFullPath = notifyEvent->StdinPath() + std::filesystem::path::preferred_separator + notifyEvent->FileName() + ".stdin";

	m_builtCommand.reserve(512);
	m_builtCommand = notifyEvent->BootstrapPath() + std::filesystem::path::preferred_separator + notifyEvent->BootstrapScriptName()
		+ " -f " + inputFileFullPath
		+ " -a " + argumentsFullPath
		+ " -s " + stdinFileFullPath
		+ " -t " + notifyEvent->InteractiveTimeout()
		+ " -i " + std::to_string(notifyEvent->Index())
		+ " -d " + notifyEvent->DependenciesPath()
		+ " -w " + notifyEvent->WorkspacePath()
		+ " -o " + notifyEvent->OutputPath()
		+ " -l " + notifyEvent->Language();
}

bool NotifyChildProcess::Launch()
{
	auto pipe = popen(m_builtCommand.c_str(), "r");
	if (!pipe)
	{
		perror("popen");
		std::cerr << "Could not execute " << m_builtCommand << std::endl;
		return false;
	}

	m_result = pclose(pipe);

	m_output = NotifyFile::ReadFile(OutputFilePath());

	return Success();
}

Multi-threading

The last feature to cover is related to how the events – the user adding a source file to the input directory – will be processed: serially or in parallel. As mentioned above, this is controlled via the ismultithreaded configuration parameter. Under the typical scenario an event comes in, gets processed, a child process is launched, and the thread blocks until the child process terminates. This works fine, but can be improved by taking advantage of parallelism. There shouldn’t be any dependencies between different users input files, so there shouldn’t be anything stopping us from running this process in parallel*.

To run in parallel, the handler for the event is called on a separate thread. This is done by taking advantage of a third party thread-pool library that is responsible for instantiating the thread pool. The event dispatch code is shown below:


void NotifyEventDispatcher::dispatchEvent(const inotify_event* pEvent)
{
	std::cout << "Read event for watch " << pEvent->wd << std::endl;

	if (pEvent->len <= 0)
	{
		std::cerr << "No file name associated with event. Watch descriptor = " << pEvent->wd << std::endl;
		return;
	}

	if (pEvent->mask & IN_CLOSE_WRITE)
	{
		std::string fileName((char*)pEvent->name);
		auto config = m_manager->Configuration();
		std::shared_ptr<NotifyEvent> notifyEvent(new NotifyEvent(config, fileName, pEvent->wd, pEvent->mask));

		if (m_manager->Configuration()->m_isMultithreaded && m_threadPool != nullptr)
		{
			m_threadPool->enqueue([this](std::shared_ptr<NotifyEvent> notifyEvent)
				{ m_handler->Handle(notifyEvent); },
				notifyEvent);
		}
		else
		{
			m_handler->Handle(notifyEvent);
		}
	}
}

This concludes the C++ portion of the file watcher. The next post will cover the Bash script portion and detail how the input file gets compiled and executed.

* I haven’t fully evaluated this. Although the code itself can run fine multi-threaded, there may be compilers that aren’t friendly to having multiple instances run at the same time.

Creating a multi-language compiler system: The inotify API (3/11)

Filed under: Programming — admin @ 10:28 PM

Table of Contents:

The compiler system, as currently described and designed in the previous posts, relies on being able to respond to user input, i.e. when a user adds in their source file to the queue (folder). Accomplishing this requires monitoring the input folder for changes and beginning the compilation and execution process when a new source file has been detected. Given that this project will be mostly in C++, there are multiple ways of implementing this feature, ranging from platform independent solutions such as std::filesystem to platform-specific ones like inotify for Linux or FindFirstChangeNotification for Windows.

As mentioned in the previous post, we are targeting the Linux platform so the inotify API seems like a natural and simple solution. This API is responsible for monitoring changes on the filesystem and notifying registered applications of those changes, which is exactly what we want. The inotify API exposes three functions: inotify_init, inotify_add_watch, and inotify_rm_watch. The first function creates an inotify instance and returns a file descriptor to it. The next two functions take this file descriptor and allow you to add or remove a watch to a directory – a watch here meaning a monitor to filesystem changes.

A mask is passed in to inotify_add_watch which specifies what type of events to notify on, i.e. file or directory move, close, open, delete, etc. The full list of flags can be found on the link for the inotify page above. Since we are interested in notifying when a user has added their file to the input folder, the mask that is needed is IN_CLOSE_WRITE. After the watch is added on the directory, the application will receive an inotify_event each time a file is added to the folder. This inotify_event structure has the following definition:

struct inotify_event {
    int      wd;       /* Watch descriptor */
    uint32_t mask;     /* Mask describing event */
    uint32_t cookie;   /* Unique cookie associating 
                          related events (for rename(2)) */
    uint32_t len;      /* Size of name field */
    char     name[];   /* Optional null-terminated name */
};

This event tells us all we need to know: which watch triggered the event, which mask this event corresponds to, as well as the file name. We will listen to these events in a callback and process them as they come in. Sample code is shown below for how to use this API. This code will serve as the template how the file watcher component, covered in the next series of posts, will be implemented.

#include <array>
#include <climits>
#include <iostream>

#include <sys/inotify.h>
#include <unistd.h>

void MonitorDirectoryChange(const int notifyFileDescriptor)
{
	constexpr auto BUFFER_SIZE = (10 * (sizeof(struct inotify_event) + NAME_MAX + 1));
	std::array<char, BUFFER_SIZE> readBuffer;
	while (true)
	{
		auto bytesRead = read(notifyFileDescriptor, readBuffer.data(), readBuffer.size());
		if (bytesRead == -1)
		{
			perror("read");
			break;
		}

		for (auto* bufferStart = readBuffer.data(); bufferStart < readBuffer.data() + bytesRead; /*Empty*/)
		{
			inotify_event* pEvent = (inotify_event*)bufferStart;
			if (pEvent->mask & IN_CLOSE_WRITE)
			{
				std::string fileName((char*)pEvent->name);
				std::cout << "File has been added: " << fileName << std::endl;
			}

			bufferStart += sizeof(inotify_event) + pEvent->len;
		}
	}
}

int main(int argc, char* argv[])
{
	if (argc != 2)
	{
		std::cerr << "Incorrect number of arguments. Specify a directory to watch";
		exit(EXIT_FAILURE);
	}

	int notifyFileDescriptor = inotify_init();
	if (notifyFileDescriptor == -1)
	{
		perror("inotify_init");
		exit(EXIT_FAILURE);
	}

	int watchDescriptor = inotify_add_watch(notifyFileDescriptor, argv[1], IN_CLOSE);
	if (watchDescriptor == -1)
	{
		perror("inotify_add_watch");
		exit(EXIT_FAILURE);
	}

	std::cout << "Beginning monitoring on " << argv[1] << std::endl;
	MonitorDirectoryChange(notifyFileDescriptor);
	std::cout << "Finished monitoring" << std::endl;

	return EXIT_SUCCESS;
}

In this example program, the directory to watch is provided via the command line. If everything is successful, a watch for IN_CLOSE_WRITE events is added. These events are then monitored in a loop and the program outputs the name of files added to the directory when they are added. A screenshot of the execution is shown below, with a few files added to the target directory:

Output of the program when files have been added to the monitored directory

The next series of posts will cover the file watcher component of the system. This component will leverage the inotify API to pick up added source code files and begin the compilation and execution process.

Creating a multi-language compiler system: Goals and Architecture (2/11)

Filed under: Programming — admin @ 10:28 PM

Table of Contents:

Introduction

This post will discuss the high level goals of the system and the architecture required to achieve them. These are generally pretty straightforward: if you look at a site like ideone, which is a front-end to a multi-language compiler system, what are some of the features made available? What information does the back-end need from the front-end in order to carry out its task?

It is easy to see what sort of information is transferred over when using ideone. After typing in some code, providing stdin input, and hitting “Run”, you can see what information is being sent over from the front-end. There are a lot of unknown/irrelevant fields, a few interesting ones like timelimit and note, which have corresponding front-end fields in the “more options” link on the page, and of course, the source code (file), stdin (input) and language (_lang) fields, all of which are required to properly construct the code on the back-end and run it.

Network activity when “Run” is pressed on ideone

The inputs to this system will be very similar to ideone – after all, you can’t have a working multi-language compiler system without being able to get the source code, console input, and target language. That said, the goals that we would like to achieve are listed below:

Goals

The user is able to:

  • Provide source code for a supported language
  • Provide command-line arguments for their program
  • View any console output that their program writes
  • Have an interactive session with the program: allow for runtime console input

The system provides:

  • An environment capable of taking input code and compiling (or interpreting) it
  • A (mostly) sandboxed environment where the code will run
  • A mechanism to capture the output of the program
  • A mechanism to provide input to the program
  • A (very basic) degree of resiliency and scalability

Taking these goals one at a time, lets discuss what is needed on the implementation end to achieve them.

Provide source code for a supported language

It is assumed that this is a given: a user has written code in a language and the system has this made available to it in order to compile/execute the provided code.

Provide command-line arguments for their program

This is relatively straightforward given that we have the code to execute. Command line arguments can be forwarded if we choose to execute the code via the terminal, or can be provided programmatically via execv or similar means if there is a dedicated process responsible for running the code.

View any console output that their program writes

This is also pretty straightforward as there are built-in ways to do this: the output can be redirected if the program is executing via the terminal, or it can be redirected programmatically with a little more work.

Have an interactive session with the program: allow for runtime console input

This is the most challenging part of the system as this requires state to be maintained between the user and the executing program. There needs to be a way to simulate the two-way interaction between a user and the executing program for this to work. The program-to-user path is a bit simpler and is just the previous goal above. The user-to-program path the the difficult one because it will require forwarding input at runtime to the program. That means that there needs to be a way to directly connect to the stdin of the executing program. This is generally a pretty complex and error prone task. The approach taken by this project will be to execute the program in a pseudoterminal, which is just one approach of many.

High-level architecture

Architecture diagram shown above. The blue path shows the full non-interactive path of input to output. The yellow boxes mark additional components required for interactive state.

At the topmost level, the design is pretty simple. The user’s source code get added to a file queue which then gets picked up by a monitoring process. This monitoring process then forwards the file to the compilation and/or execution components. The code is then executed and its output is captured and written to an output directory. There is a bit of extra work done to support interactive sessions, mainly around keeping the session state active somewhere. This session state will be the user input, which the execution component will monitor and forward to the running executable.

There are a few things here that merit more in-depth discussion though. For one, how is the input file queue and interactive session state implemented? As always, there are many different approaches here, but for the sake of simplicity, these will just be folders with files in them in the implementation. The file watcher process will monitor this folder for changes and pick up any input files that have been placed into it. Similarly, the execution component will monitor the folder for session state and forward any changes in the state file to the running executable. The input file and interactive state need to be kept track of together as the state will be required by the executable generated from the input file.

The file watcher component is the most complicated part of the system. There is a lot of work that will go in to taking the source code and making it ready for compilation. There are a lot of considerations that need to be taken. A few of the key ones are listed below:

  • What happens if the input file is for an unsupported language?
  • How will the user be notified if there is a compilation error?
  • How will the user be notified if there is a runtime error?
  • On a successful execution, how do we map the output file to the original source code?
  • Should multiple files be processed at the same time?
  • If multiple files are being processed, how will conflicts be avoided, i.e. how to prevent one compilation process from interfering with others?

In this implementation, unsupported language files will just be ignored. As far as compilation and runtime errors go, these can just be captured and written to the output file. Mapping output to input source code files should be pretty straightforward. We will assume that each language file has a unique name made of up the name plus the file extension, i.e. 123.cpp, abc.py, test.java, etc. We can generate an output file whose name is the input name plus a new extension, i.e. 123.cpp-output.log, abc.py-output.log, test.java-output.log.

Processing multiple files is an interesting challenge and a fun feature to add. There is nothing that should prevent multiple files from being compiled and executed at the same time, aside from system resources, i.e. RAM and/or CPU limits. Being able to execute in parallel should greatly speed up the overall system, at a minimal implementation cost. Instead of processing one input source code file at a time, these files – when added – can be dispatched to a thread pool where the processing takes place. However, to do this, there needs to be a way to isolate the compilation process of individual files. We want to avoid scenarios where two separate files exist in the same compilation path and might have conflicts when intermediate files are generated or cleaned up. To avoid this problem, each input file can have a dedicated workspace directory created for it from which the compilation and execution process will happen.

The diagram below summarizes at a high level how the file watcher component will work.

File watcher responsibility and flow of logic

The other complex component in this system is the execution component. Although more lightweight than the file watcher, there is some logic that is worth explaining. The execution component is responsible for two main tasks:

  • Launch the generated executable, capture its output, and write it to a file
  • Feed user input to the running executable on stdin

As mentioned before, there are lots of different ways to capture a processes output. The approach taken here will be to launch the executable as a child process and capture its output via a pseudoterminal. Likewise, input can also be provided via the pseudoterminal connection. These tasks will be accomplished by the combination of the forkpty and execv functions.

The diagram below summarizes at a high level how the execution component will work.

Execution component flow and logic

The next series of posts will go into the concrete implementation of the file watcher and execution components.

« Newer PostsOlder Posts »

Powered by WordPress