Repository: compiler-explorer/compiler-explorer
Pull request #6413 (2024-04-29 14:52:18)

Local/Remote Execution environment
First working version

This is part 1 of many for https://github.com/compiler-explorer/compiler-explorer/issues/6379

Changed in this PR:
* Move User Executable Execution into it's own independent thing
* Stored some extra things in buildResult so execution can work without knowledge of the compiler
* Be able to use a Hash and some JSON to execute

This so that later:
* We can add and use a Remote Execution Environment that pushes to a remote machine instead of directly executing (queues etc, see #6379)
* The remote machine and architecture can run the executable and return the results

Will comment out the API Endpoint before merging of course



Patch:
diff --git a/app.ts b/app.ts
index b8bad7711..92ba44195 100755
--- a/app.ts
+++ b/app.ts
@@ -576,6 +576,7 @@ async function main() {
         compileHandler,
         clientOptionsHandler,
         storageHandler,
+        compilationEnvironment,
         ceProps,
         opts,
         defArgs,
diff --git a/etc/config/compiler-explorer.defaults.properties b/etc/config/compiler-explorer.defaults.properties
index 5b21122c7..249be9508 100644
--- a/etc/config/compiler-explorer.defaults.properties
+++ b/etc/config/compiler-explorer.defaults.properties
@@ -61,3 +61,6 @@ delayCleanupTemp=false
 
 thirdPartyIntegrationEnabled=true
 statusTrackingEnabled=true
+
+# adds endpoint '/localexecution/:hash' for testing of remote execution
+localexecutionEndpoint=false
diff --git a/etc/config/csharp.defaults.properties b/etc/config/csharp.defaults.properties
index b95857689..9ef85774d 100644
--- a/etc/config/csharp.defaults.properties
+++ b/etc/config/csharp.defaults.properties
@@ -3,6 +3,7 @@ supportsBinary=true
 needsMulti=false
 compilerType=csharp
 defaultCompiler=dotnet707csharp
+executionEnvironmentClass=local-dotnet
 
 group.csharp.compilers=dotnettrunkcsharp:dotnet707csharp:dotnet703csharp:dotnet701csharp:dotnet6018csharp:dotnet6014csharp:dotnet6011csharp
 
diff --git a/etc/config/fsharp.defaults.properties b/etc/config/fsharp.defaults.properties
index bfed0718d..76281fff2 100644
--- a/etc/config/fsharp.defaults.properties
+++ b/etc/config/fsharp.defaults.properties
@@ -3,6 +3,7 @@ supportsBinary=true
 needsMulti=false
 compilerType=fsharp
 defaultCompiler=dotnet707fsharp
+executionEnvironmentClass=local-dotnet
 
 group.fsharp.compilers=dotnettrunkfsharp:dotnet707fsharp:dotnet703fsharp:dotnet701fsharp:dotnet6018fsharp:dotnet6014fsharp:dotnet6011fsharp
 
diff --git a/etc/config/vb.defaults.properties b/etc/config/vb.defaults.properties
index 046f11bbc..2db923c4b 100644
--- a/etc/config/vb.defaults.properties
+++ b/etc/config/vb.defaults.properties
@@ -3,6 +3,7 @@ supportsBinary=true
 needsMulti=false
 compilerType=vb
 defaultCompiler=dotnet707vb
+executionEnvironmentClass=local-dotnet
 
 group.vb.compilers=dotnettrunkvb:dotnet707vb:dotnet703vb:dotnet701vb:dotnet6018vb:dotnet6014vb:dotnet6011vb
 
diff --git a/lib/artifact-utils.ts b/lib/artifact-utils.ts
new file mode 100644
index 000000000..06585837c
--- /dev/null
+++ b/lib/artifact-utils.ts
@@ -0,0 +1,84 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import path from 'path';
+
+import fs from 'fs-extra';
+
+import {BufferOkFunc, BuildResult, CompilationResult} from '../types/compilation/compilation.interfaces.js';
+import {Artifact, ArtifactType} from '../types/tool.interfaces.js';
+
+import {HeaptrackWrapper} from './runtime-tools/heaptrack-wrapper.js';
+import * as utils from './utils.js';
+
+export async function addArtifactToResult(
+    result: CompilationResult,
+    filepath: string,
+    customType?: string,
+    customTitle?: string,
+    checkFunc?: BufferOkFunc,
+): Promise<void> {
+    const file_buffer = await fs.readFile(filepath);
+
+    if (checkFunc && !checkFunc(file_buffer)) return;
+
+    const artifact: Artifact = {
+        content: file_buffer.toString('base64'),
+        type: customType || 'application/octet-stream',
+        name: path.basename(filepath),
+        title: customTitle || path.basename(filepath),
+    };
+
+    if (!result.artifacts) result.artifacts = [];
+
+    result.artifacts.push(artifact);
+}
+
+export async function addHeaptrackResults(result: CompilationResult, dirPath?: string): Promise<void> {
+    let dirPathToUse: string = '';
+    if (dirPath) {
+        dirPathToUse = dirPath;
+    } else if (result.buildResult && result.buildResult.dirPath) {
+        dirPathToUse = result.buildResult.dirPath;
+    }
+
+    if (dirPathToUse === '') return;
+
+    const flamegraphFilepath = path.join(dirPathToUse, HeaptrackWrapper.FlamegraphFilename);
+    if (await utils.fileExists(flamegraphFilepath)) {
+        await addArtifactToResult(result, flamegraphFilepath, ArtifactType.heaptracktxt, 'Heaptrack results');
+    }
+}
+
+export function moveArtifactsIntoResult(movefrom: BuildResult, moveto: CompilationResult): CompilationResult {
+    if (movefrom.artifacts && movefrom.artifacts.length > 0) {
+        if (!moveto.artifacts) {
+            moveto.artifacts = [];
+        }
+        moveto.artifacts.push(...movefrom.artifacts);
+        delete movefrom.artifacts;
+    }
+
+    return moveto;
+}
diff --git a/lib/base-compiler.ts b/lib/base-compiler.ts
index d05c76bbd..ed2d9d5da 100644
--- a/lib/base-compiler.ts
+++ b/lib/base-compiler.ts
@@ -34,7 +34,6 @@ import _ from 'underscore';
 import {unique} from '../shared/common-utils.js';
 import {ParsedAsmResultLine} from '../types/asmresult/asmresult.interfaces.js';
 import {
-    BufferOkFunc,
     BuildResult,
     BuildStep,
     BypassCache,
@@ -64,8 +63,6 @@ import type {
 import type {CompilerInfo, ICompiler, PreliminaryCompilerInfo} from '../types/compiler.interfaces.js';
 import {
     BasicExecutionResult,
-    ConfiguredRuntimeTool,
-    ConfiguredRuntimeTools,
     ExecutableExecutionOptions,
     RuntimeToolType,
     UnprocessedExecResult,
@@ -75,8 +72,9 @@ import {InstructionSet} from '../types/instructionsets.js';
 import type {Language} from '../types/languages.interfaces.js';
 import type {Library, LibraryVersion, SelectedLibraryVersion} from '../types/libraries/libraries.interfaces.js';
 import type {ResultLine} from '../types/resultline/resultline.interfaces.js';
-import {type Artifact, ArtifactType, type ToolResult, type ToolTypeKey} from '../types/tool.interfaces.js';
+import {type ToolResult, type ToolTypeKey} from '../types/tool.interfaces.js';
 
+import {moveArtifactsIntoResult} from './artifact-utils.js';
 import {unwrap} from './assert.js';
 import type {BuildEnvDownloadInfo} from './buildenvsetup/buildenv.interfaces.js';
 import {BuildEnvSetupBase, getBuildEnvTypeByKey} from './buildenvsetup/index.js';
@@ -87,6 +85,8 @@ import {ClangCParser, ClangParser, GCCCParser, GCCParser, ICCParser} from './com
 import {BaseDemangler, getDemanglerTypeByKey} from './demangler/index.js';
 import {LLVMIRDemangler} from './demangler/llvm.js';
 import * as exec from './exec.js';
+import {IExecutionEnvironment} from './execution/execution-env.interfaces.js';
+import {getExecutionEnvironmentByKey} from './execution/index.js';
 import {ExternalParserBase} from './external-parsers/base.js';
 import {getExternalParserByKey} from './external-parsers/index.js';
 import {InstructionSets} from './instructionsets.js';
@@ -196,6 +196,7 @@ export class BaseCompiler implements ICompiler {
         help: 'Time spent on objdump and parsing of objdumps',
         labelNames: [],
     });
+    protected executionEnvironmentClass: any;
 
     constructor(compilerInfo: PreliminaryCompilerInfo & {disabledFilters?: string[]}, env: CompilationEnvironment) {
         // Information about our compiler
@@ -208,7 +209,7 @@ export class BaseCompiler implements ICompiler {
         this.compileFilename = `example${this.lang.extensions[0]}`;
         this.env = env;
         // Partial application of compilerProps with the proper language id applied to it
-        this.compilerProps = _.partial(this.env.compilerProps as any, this.lang.id);
+        this.compilerProps = this.env.getCompilerPropsForLanguage(this.lang.id);
         this.compiler.supportsIntel = !!this.compiler.intelAsm;
 
         this.alwaysResetLdPath = this.env.ceProps('alwaysResetLdPath');
@@ -217,6 +218,9 @@ export class BaseCompiler implements ICompiler {
         this.stubText = this.compilerProps('stubText', '');
         this.compilerWrapper = this.compilerProps('compiler-wrapper');
 
+        const executionEnvironmentClassStr = this.compilerProps<string>('executionEnvironmentClass', 'local');
+        this.executionEnvironmentClass = getExecutionEnvironmentByKey(executionEnvironmentClassStr);
+
         if (!this.compiler.options) this.compiler.options = '';
         if (!this.compiler.optArg) this.compiler.optArg = '';
         if (!this.compiler.supportsOptOutput) this.compiler.supportsOptOutput = false;
@@ -637,47 +641,6 @@ export class BaseCompiler implements ICompiler {
         return result;
     }
 
-    processExecutionResult(input: UnprocessedExecResult, inputFilename?: string): BasicExecutionResult {
-        const start = performance.now();
-        const stdout = utils.parseOutput(input.stdout, inputFilename);
-        const stderr = utils.parseOutput(input.stderr, inputFilename);
-        const end = performance.now();
-        return {
-            ...input,
-            stdout,
-            stderr,
-            processExecutionResultTime: end - start,
-        };
-    }
-
-    processUserExecutableExecutionResult(
-        input: UnprocessedExecResult,
-        stdErrlineParseOptions: utils.LineParseOptions,
-    ): BasicExecutionResult {
-        const start = performance.now();
-        const stdout = utils.parseOutput(input.stdout, undefined, undefined, []);
-        const stderr = utils.parseOutput(input.stderr, undefined, undefined, stdErrlineParseOptions);
-        const end = performance.now();
-        return {
-            ...input,
-            stdout,
-            stderr,
-            processExecutionResultTime: end - start,
-        };
-    }
-
-    getEmptyExecutionResult(): BasicExecutionResult {
-        return {
-            code: -1,
-            okToCache: false,
-            filenameTransform: x => x,
-            stdout: [],
-            stderr: [],
-            execTime: '',
-            timedOut: false,
-        };
-    }
-
     transformToCompilationResult(input: UnprocessedExecResult, inputFilename): CompilationResult {
         const transformedInput = input.filenameTransform(inputFilename);
 
@@ -688,99 +651,6 @@ export class BaseCompiler implements ICompiler {
         };
     }
 
-    protected setEnvironmentVariablesFromRuntime(
-        configuredTools: ConfiguredRuntimeTools,
-        execOptions: ExecutionOptions,
-    ) {
-        for (const runtime of configuredTools) {
-            if (runtime.name === RuntimeToolType.env) {
-                for (const env of runtime.options) {
-                    if (!execOptions.env) execOptions.env = {};
-
-                    execOptions.env[env.name] = env.value;
-                }
-            }
-        }
-    }
-
-    protected async execBinaryMaybeWrapped(
-        executable: string,
-        args: string[],
-        execOptions: ExecutionOptions,
-        executeParameters: ExecutableExecutionOptions,
-        homeDir: string,
-    ): Promise<BasicExecutionResult> {
-        let runWithHeaptrack: ConfiguredRuntimeTool | undefined = undefined;
-
-        if (!execOptions.env) execOptions.env = {};
-
-        if (executeParameters.runtimeTools) {
-            this.setEnvironmentVariablesFromRuntime(executeParameters.runtimeTools, execOptions);
-
-            for (const runtime of executeParameters.runtimeTools) {
-                if (runtime.name === RuntimeToolType.heaptrack) {
-                    runWithHeaptrack = runtime;
-                }
-            }
-        }
-
-        if (runWithHeaptrack && HeaptrackWrapper.isSupported(this.env)) {
-            const wrapper = new HeaptrackWrapper(
-                homeDir,
-                exec.sandbox,
-                this.exec,
-                runWithHeaptrack.options,
-                this.env.ceProps,
-                this.sandboxType,
-            );
-            const execResult: UnprocessedExecResult = await wrapper.exec(executable, args, execOptions);
-            return this.processUserExecutableExecutionResult(execResult, [utils.LineParseOption.AtFileLine]);
-        } else {
-            const execResult: UnprocessedExecResult = await exec.sandbox(executable, args, execOptions);
-            return this.processUserExecutableExecutionResult(execResult, []);
-        }
-    }
-
-    async execBinary(
-        executable: string,
-        maxSize: number,
-        executeParameters: ExecutableExecutionOptions,
-        homeDir: string,
-    ): Promise<BasicExecutionResult> {
-        // We might want to save this in the compilation environment once execution is made available
-        const timeoutMs = this.env.ceProps('binaryExecTimeoutMs', 2000);
-        try {
-            const execOptions: ExecutionOptions = {
-                maxOutput: maxSize,
-                timeoutMs: timeoutMs,
-                ldPath: _.union(this.compiler.ldPath, executeParameters.ldPath),
-                input: executeParameters.stdin,
-                env: executeParameters.env,
-                customCwd: homeDir,
-                appHome: homeDir,
-            };
-
-            return this.execBinaryMaybeWrapped(
-                executable,
-                executeParameters.args,
-                execOptions,
-                executeParameters,
-                homeDir,
-            );
-        } catch (err: UnprocessedExecResult | any) {
-            if (err.code && err.stderr) {
-                return this.processExecutionResult(err);
-            } else {
-                return {
-                    ...this.getEmptyExecutionResult(),
-                    stdout: err.stdout ? utils.parseOutput(err.stdout) : [],
-                    stderr: err.stderr ? utils.parseOutput(err.stderr) : [],
-                    code: err.code === undefined ? -1 : err.code,
-                };
-            }
-        }
-    }
-
     protected filename(fn) {
         return fn;
     }
@@ -1864,29 +1734,6 @@ export class BaseCompiler implements ICompiler {
         }
     }
 
-    async addArtifactToResult(
-        result: CompilationResult,
-        filepath: string,
-        customType?: string,
-        customTitle?: string,
-        checkFunc?: BufferOkFunc,
-    ) {
-        const file_buffer = await fs.readFile(filepath);
-
-        if (checkFunc && !checkFunc(file_buffer)) return;
-
-        const artifact: Artifact = {
-            content: file_buffer.toString('base64'),
-            type: customType || 'application/octet-stream',
-            name: path.basename(filepath),
-            title: customTitle || path.basename(filepath),
-        };
-
-        if (!result.artifacts) result.artifacts = [];
-
-        result.artifacts.push(artifact);
-    }
-
     protected async writeMultipleFiles(files: any[], dirPath: string) {
         const filesToWrite: Promise<void>[] = [];
 
@@ -1989,7 +1836,7 @@ export class BaseCompiler implements ICompiler {
             if (buildResults) return buildResults;
         }
 
-        let compilationResult;
+        let compilationResult: BuildResult;
         try {
             compilationResult = await this.buildExecutableInFolder(key, dirPath);
             if (compilationResult.code !== 0) {
@@ -1999,6 +1846,12 @@ export class BaseCompiler implements ICompiler {
             return this.handleUserError(e, dirPath);
         }
 
+        compilationResult.preparedLdPaths = _.union(
+            this.compiler.ldPath,
+            this.getSharedLibraryPathsAsLdLibraryPathsForExecution(key.libraries, dirPath),
+        );
+        compilationResult.defaultExecOptions = this.getDefaultExecOptions();
+
         await this.storePackageWithExecutable(key, dirPath, compilationResult);
 
         if (!compilationResult.dirPath) {
@@ -2048,7 +1901,8 @@ export class BaseCompiler implements ICompiler {
         try {
             await fs.writeFile(path.join(dirPath, compilationResultFilename), JSON.stringify(compilationResult));
             await this.packager.package(dirPath, packagedFile);
-            await this.env.executablePut(key, packagedFile);
+            const hash = await this.env.executablePut(key, packagedFile);
+            logger.debug('Storing ' + hash);
         } catch (err) {
             logger.error('Caught an error trying to put to cache: ', {err});
         } finally {
@@ -2056,27 +1910,26 @@ export class BaseCompiler implements ICompiler {
         }
     }
 
-    runExecutable(executable: string, executeParameters: ExecutableExecutionOptions, homeDir) {
-        const maxExecOutputSize = this.env.ceProps('max-executable-output-size', 32 * 1024);
+    protected processExecutionResult(input: UnprocessedExecResult, inputFilename?: string): BasicExecutionResult {
+        return utils.processExecutionResult(input, inputFilename);
+    }
 
+    async runExecutable(
+        executable: string,
+        executeParameters: ExecutableExecutionOptions,
+        homeDir,
+    ): Promise<BasicExecutionResult> {
         const execOptionsCopy: ExecutableExecutionOptions = JSON.parse(
             JSON.stringify(executeParameters),
         ) as ExecutableExecutionOptions;
 
-        // Hardcoded fix for #2339. Ideally I'd have a config option for this, but for now this is plenty good enough.
-        execOptionsCopy.env = {
-            ASAN_OPTIONS: 'color=always',
-            UBSAN_OPTIONS: 'color=always',
-            MSAN_OPTIONS: 'color=always',
-            LSAN_OPTIONS: 'color=always',
-            ...executeParameters.env,
-        };
-
         if (this.compiler.executionWrapper) {
             execOptionsCopy.args = [...this.compiler.executionWrapperArgs, executable, ...execOptionsCopy.args];
             executable = this.compiler.executionWrapper;
         }
-        return this.execBinary(executable, maxExecOutputSize, execOptionsCopy, homeDir);
+
+        const execEnv: IExecutionEnvironment = new this.executionEnvironmentClass(this.env);
+        return execEnv.execBinary(executable, execOptionsCopy, homeDir);
     }
 
     protected fixExecuteParametersForInterpreting(executeParameters, outputFilename, key) {
@@ -2160,46 +2013,23 @@ export class BaseCompiler implements ICompiler {
             };
         }
 
-        executeParameters.ldPath = this.getSharedLibraryPathsAsLdLibraryPathsForExecution(
-            key.libraries,
-            buildResult.dirPath,
-        );
+        if (buildResult.preparedLdPaths) {
+            executeParameters.ldPath = buildResult.preparedLdPaths;
+        } else {
+            executeParameters.ldPath = this.getSharedLibraryPathsAsLdLibraryPathsForExecution(
+                key.libraries,
+                buildResult.dirPath,
+            );
+        }
+
         const result = await this.runExecutable(buildResult.executableFilename, executeParameters, buildResult.dirPath);
-        return this.moveArtifactsIntoResult(buildResult, {
+        return moveArtifactsIntoResult(buildResult, {
             ...result,
             didExecute: true,
             buildResult: buildResult,
         });
     }
 
-    moveArtifactsIntoResult(movefrom: BuildResult, moveto: CompilationResult): CompilationResult {
-        if (movefrom.artifacts && movefrom.artifacts.length > 0) {
-            if (!moveto.artifacts) {
-                moveto.artifacts = [];
-            }
-            moveto.artifacts.push(...movefrom.artifacts);
-            delete movefrom.artifacts;
-        }
-
-        return moveto;
-    }
-
-    async addHeaptrackResults(result: CompilationResult, dirPath?: string) {
-        let dirPathToUse: string = '';
-        if (dirPath) {
-            dirPathToUse = dirPath;
-        } else if (result.buildResult && result.buildResult.dirPath) {
-            dirPathToUse = result.buildResult.dirPath;
-        }
-
-        if (dirPathToUse === '') return;
-
-        const flamegraphFilepath = path.join(dirPathToUse, HeaptrackWrapper.FlamegraphFilename);
-        if (await utils.fileExists(flamegraphFilepath)) {
-            await this.addArtifactToResult(result, flamegraphFilepath, ArtifactType.heaptracktxt, 'Heaptrack results');
-        }
-    }
-
     async handleExecution(
         key,
         executeParameters: ExecutableExecutionOptions,
@@ -2216,14 +2046,6 @@ export class BaseCompiler implements ICompiler {
 
         const result = await this.doExecution(key, executeParameters, bypassCache);
 
-        if (executeParameters.runtimeTools) {
-            for (const runtime of executeParameters.runtimeTools) {
-                if (runtime.name === RuntimeToolType.heaptrack) {
-                    await this.addHeaptrackResults(result);
-                }
-            }
-        }
-
         if (!bypassExecutionCache(bypassCache)) {
             await this.env.cachePut(execKey, result, undefined);
         }
@@ -2523,11 +2345,12 @@ export class BaseCompiler implements ICompiler {
         return this.processExecutionResult(result);
     }
 
-    handleUserError(error, dirPath) {
+    handleUserError(error, dirPath): CompilationResult {
         return {
             dirPath,
             okToCache: false,
             code: -1,
+            timedOut: false,
             asm: [{text: `<${error.message}>`}],
             stdout: [],
             stderr: [{text: `<${error.message}>`}],
@@ -2611,7 +2434,7 @@ export class BaseCompiler implements ICompiler {
         }
     }
 
-    async cmake(files, key, bypassCache: BypassCache) {
+    async cmake(files, key, bypassCache: BypassCache): Promise<CompilationResult> {
         // key = {source, options, backendOptions, filters, bypassCache, tools, executeParameters, libraries};
 
         if (!this.compiler.supportsBinary) {
@@ -2651,11 +2474,11 @@ export class BaseCompiler implements ICompiler {
 
         const outputFilename = this.getExecutableFilename(path.join(dirPath, 'build'), this.outputFilebase, key);
 
-        let fullResult = bypassExecutionCache(bypassCache)
+        let fullResult: CompilationResult = bypassExecutionCache(bypassCache)
             ? null
             : await this.loadPackageWithExecutable(cacheKey, dirPath);
         if (fullResult) {
-            fullResult.fetchedFromCache = true;
+            fullResult.retreivedFromCache = true;
 
             delete fullResult.inputFilename;
             delete fullResult.executableFilename;
@@ -2677,6 +2500,10 @@ export class BaseCompiler implements ICompiler {
             const makeExecParams = this.createCmakeExecParams(execParams, dirPath, libsAndOptions, toolchainPath);
 
             fullResult = {
+                code: 0,
+                timedOut: false,
+                stdout: [],
+                stderr: [],
                 buildsteps: [],
                 inputFilename: writeSummary.inputFilename,
             };
@@ -2703,6 +2530,9 @@ export class BaseCompiler implements ICompiler {
             if (cmakeStepResult.code !== 0) {
                 fullResult.result = {
                     dirPath,
+                    timedOut: false,
+                    stdout: [],
+                    stderr: [],
                     okToCache: false,
                     code: cmakeStepResult.code,
                     asm: [{text: '<Build failed>'}],
@@ -2722,6 +2552,9 @@ export class BaseCompiler implements ICompiler {
             if (makeStepResult.code !== 0) {
                 fullResult.result = {
                     dirPath,
+                    timedOut: false,
+                    stdout: [],
+                    stderr: [],
                     okToCache: false,
                     code: makeStepResult.code,
                     asm: [{text: '<Build failed>'}],
@@ -2731,7 +2564,12 @@ export class BaseCompiler implements ICompiler {
 
             fullResult.result = {
                 dirPath,
+                code: 0,
+                timedOut: false,
+                stdout: [],
+                stderr: [],
                 okToCache: true,
+                compilationOptions: this.getUsedEnvironmentVariableFlags(makeExecParams),
             };
 
             if (!key.backendOptions.skipAsm) {
@@ -2743,29 +2581,21 @@ export class BaseCompiler implements ICompiler {
                 fullResult.result = asmResult;
             }
 
-            fullResult.result.compilationOptions = this.getUsedEnvironmentVariableFlags(makeExecParams);
-
             fullResult.code = 0;
-            _.each(fullResult.buildsteps, function (step) {
-                fullResult.code += step.code;
-            });
+            if (fullResult.buildsteps) {
+                _.each(fullResult.buildsteps, function (step) {
+                    fullResult.code += step.code;
+                });
+            }
 
             await this.storePackageWithExecutable(cacheKey, dirPath, fullResult);
         }
 
-        fullResult.result.dirPath = dirPath;
+        if (fullResult.result) fullResult.result.dirPath = dirPath;
 
         if (this.compiler.supportsExecute && doExecute) {
             fullResult.execResult = await this.runExecutable(outputFilename, executeOptions, dirPath);
             fullResult.didExecute = true;
-
-            if (executeOptions.runtimeTools) {
-                for (const runtime of executeOptions.runtimeTools) {
-                    if (runtime.name === RuntimeToolType.heaptrack) {
-                        await this.addHeaptrackResults(fullResult, dirPath);
-                    }
-                }
-            }
         }
 
         const optOutput = undefined;
@@ -2785,7 +2615,7 @@ export class BaseCompiler implements ICompiler {
             path.join(dirPath, 'build'),
         );
 
-        delete fullResult.result.dirPath;
+        if (fullResult.result) delete fullResult.result.dirPath;
 
         this.cleanupResult(fullResult);
 
diff --git a/lib/compilation-env.ts b/lib/compilation-env.ts
index 40b90e57a..9b16ab0b2 100644
--- a/lib/compilation-env.ts
+++ b/lib/compilation-env.ts
@@ -41,6 +41,8 @@ import type {PropertyGetter} from './properties.interfaces.js';
 import {CompilerProps} from './properties.js';
 import {createStatsNoter, IStatsNoter} from './stats.js';
 
+type PropFunc = (string, any?) => any;
+
 export class CompilationEnvironment {
     ceProps: PropertyGetter;
     compilationQueue: CompilationQueue;
@@ -156,7 +158,7 @@ export class CompilationEnvironment {
         return this.compilerCache.put(key, JSON.stringify(result), creator);
     }
 
-    async executableGet(object: CacheableValue, destinationFolder: string) {
+    async executableGet(object: CacheableValue, destinationFolder: string): Promise<string | null> {
         const key = BaseCache.hash(object) + '_exec';
         const result = await this.executableCache.get(key);
         if (!result.hit) return null;
@@ -165,9 +167,10 @@ export class CompilationEnvironment {
         return filepath;
     }
 
-    executablePut(object: CacheableValue, filepath: string) {
+    async executablePut(object: CacheableValue, filepath: string): Promise<string> {
         const key = BaseCache.hash(object) + '_exec';
-        return this.executableCache.put(key, fs.readFileSync(filepath));
+        await this.executableCache.put(key, fs.readFileSync(filepath));
+        return key;
     }
 
     enqueue<T>(job: Job<T>, options?: EnqueueOptions) {
@@ -177,4 +180,8 @@ export class CompilationEnvironment {
     findBadOptions(options: string[]) {
         return options.filter(option => !this.okOptions.test(option) || this.badOptions.test(option));
     }
+
+    getCompilerPropsForLanguage(languageId: string): PropFunc {
+        return _.partial(this.compilerProps as any, languageId);
+    }
 }
diff --git a/lib/compilers/beebasm.ts b/lib/compilers/beebasm.ts
index b1d5a2aea..de7edcb75 100644
--- a/lib/compilers/beebasm.ts
+++ b/lib/compilers/beebasm.ts
@@ -29,6 +29,7 @@ import fs from 'fs-extra';
 import type {ExecutionOptions} from '../../types/compilation/compilation.interfaces.js';
 import type {PreliminaryCompilerInfo} from '../../types/compiler.interfaces.js';
 import {ArtifactType} from '../../types/tool.interfaces.js';
+import {addArtifactToResult} from '../artifact-utils.js';
 import {BaseCompiler} from '../base-compiler.js';
 import {AsmParserBeebAsm} from '../parsers/asm-parser-beebasm.js';
 import * as utils from '../utils.js';
@@ -84,7 +85,7 @@ export class BeebAsmCompiler extends BaseCompiler {
         if (result.code === 0 && options.includes('-v')) {
             const diskfile = path.join(dirPath, 'disk.ssd');
             if (await utils.fileExists(diskfile)) {
-                await this.addArtifactToResult(result, diskfile, ArtifactType.bbcdiskimage);
+                await addArtifactToResult(result, diskfile, ArtifactType.bbcdiskimage);
 
                 if (!hasBootOption) {
                     if (!result.hints) result.hints = [];
diff --git a/lib/compilers/carbon.ts b/lib/compilers/carbon.ts
index 65a69efd7..6e090cebe 100644
--- a/lib/compilers/carbon.ts
+++ b/lib/compilers/carbon.ts
@@ -84,6 +84,7 @@ export class CarbonCompiler extends BaseCompiler {
                 stderr: [],
                 code: code,
                 didExecute: true,
+                timedOut: false,
                 buildResult: {
                     code: 0,
                     timedOut: false,
diff --git a/lib/compilers/cc65.ts b/lib/compilers/cc65.ts
index 64102fcd6..b53eae372 100644
--- a/lib/compilers/cc65.ts
+++ b/lib/compilers/cc65.ts
@@ -31,6 +31,7 @@ import type {CompilationResult} from '../../types/compilation/compilation.interf
 import type {PreliminaryCompilerInfo} from '../../types/compiler.interfaces.js';
 import type {ParseFiltersAndOutputOptions} from '../../types/features/filters.interfaces.js';
 import {ArtifactType} from '../../types/tool.interfaces.js';
+import {addArtifactToResult} from '../artifact-utils.js';
 import {BaseCompiler} from '../base-compiler.js';
 import {CC65AsmParser} from '../parsers/asm-parser-cc65.js';
 import * as utils from '../utils.js';
@@ -118,14 +119,14 @@ export class Cc65Compiler extends BaseCompiler {
         const dirPath = path.dirname(outputFilename);
         const nesFile = path.join(dirPath, 'example.nes');
         if (await utils.fileExists(nesFile)) {
-            await this.addArtifactToResult(res, nesFile, ArtifactType.nesrom);
+            await addArtifactToResult(res, nesFile, ArtifactType.nesrom);
         }
 
         if (result.compilationOptions?.includes('c64') && (await utils.fileExists(outputFilename))) {
             if (outputFilename.endsWith('.prg')) {
-                await this.addArtifactToResult(res, outputFilename, ArtifactType.c64prg);
+                await addArtifactToResult(res, outputFilename, ArtifactType.c64prg);
             } else {
-                await this.addArtifactToResult(
+                await addArtifactToResult(
                     res,
                     outputFilename,
                     ArtifactType.c64prg,
diff --git a/lib/compilers/clang.ts b/lib/compilers/clang.ts
index ffb83d1c6..e69f298f7 100644
--- a/lib/compilers/clang.ts
+++ b/lib/compilers/clang.ts
@@ -37,6 +37,7 @@ import type {PreliminaryCompilerInfo} from '../../types/compiler.interfaces.js';
 import type {ExecutableExecutionOptions, UnprocessedExecResult} from '../../types/execution/execution.interfaces.js';
 import type {ParseFiltersAndOutputOptions} from '../../types/features/filters.interfaces.js';
 import {ArtifactType} from '../../types/tool.interfaces.js';
+import {addArtifactToResult} from '../artifact-utils.js';
 import {BaseCompiler} from '../base-compiler.js';
 import {CompilationEnvironment} from '../compilation-env.js';
 import {AmdgpuAsmParser} from '../parsers/asm-parser-amdgpu.js';
@@ -108,15 +109,9 @@ export class ClangCompiler extends BaseCompiler {
         }
 
         if (jsonFilepath) {
-            this.addArtifactToResult(
-                result,
-                jsonFilepath,
-                ArtifactType.timetrace,
-                'Trace events JSON',
-                (buffer: Buffer) => {
-                    return buffer.toString('utf8').startsWith('{"traceEvents":[');
-                },
-            );
+            addArtifactToResult(result, jsonFilepath, ArtifactType.timetrace, 'Trace events JSON', (buffer: Buffer) => {
+                return buffer.toString('utf8').startsWith('{"traceEvents":[');
+            });
         }
     }
 
diff --git a/lib/compilers/dotnet.ts b/lib/compilers/dotnet.ts
index e6494938d..cfe284304 100644
--- a/lib/compilers/dotnet.ts
+++ b/lib/compilers/dotnet.ts
@@ -25,20 +25,15 @@
 import path from 'path';
 
 import fs from 'fs-extra';
-import _ from 'underscore';
 
 import type {CompilationResult, ExecutionOptions} from '../../types/compilation/compilation.interfaces.js';
 import type {PreliminaryCompilerInfo} from '../../types/compiler.interfaces.js';
-import {
-    type BasicExecutionResult,
-    type ExecutableExecutionOptions,
-} from '../../types/execution/execution.interfaces.js';
+import {ExecutableExecutionOptions} from '../../types/execution/execution.interfaces.js';
 import type {ParseFiltersAndOutputOptions} from '../../types/features/filters.interfaces.js';
 import {BaseCompiler} from '../base-compiler.js';
+import {AssemblyName, DotnetExtraConfiguration} from '../execution/dotnet-execution-env.js';
+import {IExecutionEnvironment} from '../execution/execution-env.interfaces.js';
 import {DotNetAsmParser} from '../parsers/asm-parser-dotnet.js';
-import * as utils from '../utils.js';
-
-const AssemblyName = 'CompilerExplorer';
 
 class DotNetCompiler extends BaseCompiler {
     private readonly sdkBaseDir: string;
@@ -429,43 +424,6 @@ class DotNetCompiler extends BaseCompiler {
         return this.compilerOptions;
     }
 
-    override async execBinary(
-        executable: string,
-        maxSize: number,
-        executeParameters: ExecutableExecutionOptions,
-        homeDir: string,
-    ): Promise<BasicExecutionResult> {
-        const programDir = path.dirname(executable);
-        const programOutputPath = path.join(programDir, 'bin', this.buildConfig, this.targetFramework);
-        const programDllPath = path.join(programOutputPath, `${AssemblyName}.dll`);
-        const execOptions = this.getDefaultExecOptions();
-        execOptions.maxOutput = maxSize;
-        execOptions.timeoutMs = this.env.ceProps('binaryExecTimeoutMs', 2000);
-        execOptions.ldPath = _.union(this.compiler.ldPath, executeParameters.ldPath);
-        execOptions.customCwd = homeDir;
-        execOptions.appHome = homeDir;
-        execOptions.env = executeParameters.env;
-        execOptions.env.DOTNET_EnableWriteXorExecute = '0';
-        execOptions.env.DOTNET_CLI_HOME = programDir;
-        execOptions.env.CORE_ROOT = this.clrBuildDir;
-        execOptions.input = executeParameters.stdin;
-        const execArgs = ['-p', 'System.Runtime.TieredCompilation=false', programDllPath, ...executeParameters.args];
-        try {
-            return this.execBinaryMaybeWrapped(this.corerunPath, execArgs, execOptions, executeParameters, homeDir);
-        } catch (err: any) {
-            if (err.code && err.stderr) {
-                return this.processExecutionResult(err);
-            } else {
-                return {
-                    ...this.getEmptyExecutionResult(),
-                    stdout: err.stdout ? utils.parseOutput(err.stdout) : [],
-                    stderr: err.stderr ? utils.parseOutput(err.stderr) : [],
-                    code: err.code === undefined ? -1 : err.code,
-                };
-            }
-        }
-    }
-
     async getRuntimeVersion() {
         const versionFilePath = `${this.clrBuildDir}/version.txt`;
         if (fs.existsSync(versionFilePath)) {
@@ -532,6 +490,28 @@ class DotNetCompiler extends BaseCompiler {
 
         return result;
     }
+
+    override runExecutable(executable: string, executeParameters: ExecutableExecutionOptions, homeDir) {
+        const execOptionsCopy: ExecutableExecutionOptions = JSON.parse(
+            JSON.stringify(executeParameters),
+        ) as ExecutableExecutionOptions;
+
+        if (this.compiler.executionWrapper) {
+            execOptionsCopy.args = [...this.compiler.executionWrapperArgs, executable, ...execOptionsCopy.args];
+            executable = this.compiler.executionWrapper;
+        }
+
+        const extraConfiguration: DotnetExtraConfiguration = {
+            buildConfig: this.buildConfig,
+            clrBuildDir: this.clrBuildDir,
+            langVersion: this.langVersion,
+            targetFramework: this.targetFramework,
+            corerunPath: this.corerunPath,
+        };
+
+        const execEnv: IExecutionEnvironment = new this.executionEnvironmentClass(this.env);
+        return execEnv.execBinary(executable, execOptionsCopy, homeDir, extraConfiguration);
+    }
 }
 
 export class CSharpCompiler extends DotNetCompiler {
diff --git a/lib/compilers/llvm-mos.ts b/lib/compilers/llvm-mos.ts
index 026a0fe45..6d412155e 100644
--- a/lib/compilers/llvm-mos.ts
+++ b/lib/compilers/llvm-mos.ts
@@ -28,6 +28,7 @@ import type {CompilationResult} from '../../types/compilation/compilation.interf
 import type {PreliminaryCompilerInfo} from '../../types/compiler.interfaces.js';
 import type {ParseFiltersAndOutputOptions} from '../../types/features/filters.interfaces.js';
 import {ArtifactType} from '../../types/tool.interfaces.js';
+import {addArtifactToResult} from '../artifact-utils.js';
 import * as utils from '../utils.js';
 
 import {ClangCompiler} from './clang.js';
@@ -88,7 +89,7 @@ export class LLVMMOSCompiler extends ClangCompiler {
             }
 
             if (await utils.fileExists(nesFile)) {
-                await this.addArtifactToResult(res, nesFile, ArtifactType.nesrom);
+                await addArtifactToResult(res, nesFile, ArtifactType.nesrom);
             }
         } else if (this.compiler.exe.includes('c64')) {
             let prgFile = outputFilename;
@@ -97,7 +98,7 @@ export class LLVMMOSCompiler extends ClangCompiler {
             }
 
             if (await utils.fileExists(prgFile)) {
-                await this.addArtifactToResult(res, prgFile, ArtifactType.c64prg);
+                await addArtifactToResult(res, prgFile, ArtifactType.c64prg);
             }
         }
 
diff --git a/lib/compilers/madpascal.ts b/lib/compilers/madpascal.ts
index 249ac3041..8ab3171d0 100644
--- a/lib/compilers/madpascal.ts
+++ b/lib/compilers/madpascal.ts
@@ -30,6 +30,7 @@ import {CompilationResult, ExecutionOptions} from '../../types/compilation/compi
 import {PreliminaryCompilerInfo} from '../../types/compiler.interfaces.js';
 import type {ParseFiltersAndOutputOptions} from '../../types/features/filters.interfaces.js';
 import {ArtifactType} from '../../types/tool.interfaces.js';
+import {addArtifactToResult} from '../artifact-utils.js';
 import {BaseCompiler, c_value_placeholder} from '../base-compiler.js';
 import {CompilationEnvironment} from '../compilation-env.js';
 import {MadsAsmParser} from '../parsers/asm-parser-mads.js';
@@ -144,7 +145,7 @@ export class MadPascalCompiler extends BaseCompiler {
             if (assemblerResult.code === 0 && this.isTargettingC64(options)) {
                 const diskfile = path.join(tmpDir, 'output.obx');
                 if (await utils.fileExists(diskfile)) {
-                    await this.addArtifactToResult(result, diskfile, ArtifactType.c64prg, 'output.prg');
+                    await addArtifactToResult(result, diskfile, ArtifactType.c64prg, 'output.prg');
                 }
             }
 
diff --git a/lib/compilers/z88dk.ts b/lib/compilers/z88dk.ts
index 7997859d5..cdd8f0c73 100644
--- a/lib/compilers/z88dk.ts
+++ b/lib/compilers/z88dk.ts
@@ -28,6 +28,7 @@ import type {ExecutionOptions} from '../../types/compilation/compilation.interfa
 import type {PreliminaryCompilerInfo} from '../../types/compiler.interfaces.js';
 import type {ParseFiltersAndOutputOptions} from '../../types/features/filters.interfaces.js';
 import {ArtifactType} from '../../types/tool.interfaces.js';
+import {addArtifactToResult} from '../artifact-utils.js';
 import {BaseCompiler} from '../base-compiler.js';
 import {logger} from '../logger.js';
 import {AsmParserZ88dk} from '../parsers/asm-parser-z88dk.js';
@@ -182,12 +183,12 @@ export class z88dkCompiler extends BaseCompiler {
         if (result.code === 0 && filters.binary) {
             const tapeFilepath = path.join(result.dirPath, this.getTapefilename());
             if (await utils.fileExists(tapeFilepath)) {
-                await this.addArtifactToResult(result, tapeFilepath, ArtifactType.zxtape);
+                await addArtifactToResult(result, tapeFilepath, ArtifactType.zxtape);
             }
 
             const smsFilepath = path.join(result.dirPath, this.getSmsfilename());
             if (await utils.fileExists(smsFilepath)) {
-                await this.addArtifactToResult(result, smsFilepath, ArtifactType.smsrom);
+                await addArtifactToResult(result, smsFilepath, ArtifactType.smsrom);
             }
         }
 
diff --git a/lib/execution/_all.ts b/lib/execution/_all.ts
new file mode 100644
index 000000000..3c44a7538
--- /dev/null
+++ b/lib/execution/_all.ts
@@ -0,0 +1,26 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+export {LocalExecutionEnvironment} from './base-execution-env.js';
+export {DotnetExecutionEnvironment} from './dotnet-execution-env.js';
diff --git a/lib/execution/base-execution-env.ts b/lib/execution/base-execution-env.ts
new file mode 100644
index 000000000..1b4b11435
--- /dev/null
+++ b/lib/execution/base-execution-env.ts
@@ -0,0 +1,311 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import os from 'os';
+import path from 'path';
+
+import fs from 'fs-extra';
+import temp from 'temp';
+
+import {BuildResult, ExecutionOptions, ExecutionParams} from '../../types/compilation/compilation.interfaces.js';
+import {
+    BasicExecutionResult,
+    ConfiguredRuntimeTool,
+    ConfiguredRuntimeTools,
+    ExecutableExecutionOptions,
+    RuntimeToolType,
+    UnprocessedExecResult,
+} from '../../types/execution/execution.interfaces.js';
+import {addHeaptrackResults} from '../artifact-utils.js';
+import {assert, unwrap} from '../assert.js';
+import {CompilationEnvironment} from '../compilation-env.js';
+import * as exec from '../exec.js';
+import {logger} from '../logger.js';
+import {Packager} from '../packager.js';
+import {propsFor} from '../properties.js';
+import {HeaptrackWrapper} from '../runtime-tools/heaptrack-wrapper.js';
+import * as utils from '../utils.js';
+
+import {ExecutablePackageCacheMiss, IExecutionEnvironment} from './execution-env.interfaces.js';
+
+export class LocalExecutionEnvironment implements IExecutionEnvironment {
+    protected packager: Packager;
+    protected dirPath: string;
+    protected buildResult: BuildResult | undefined;
+    protected environment: CompilationEnvironment;
+    protected timeoutMs: number;
+    protected sandboxType: string;
+    protected useSanitizerEnvHints: boolean;
+    protected maxExecOutputSize: number;
+
+    static get key() {
+        return 'local';
+    }
+
+    constructor(environment: CompilationEnvironment) {
+        this.environment = environment;
+        this.timeoutMs = this.environment.ceProps('binaryExecTimeoutMs', 2000);
+        this.maxExecOutputSize = this.environment.ceProps('max-executable-output-size', 32 * 1024);
+
+        this.useSanitizerEnvHints = true;
+
+        const execProps = propsFor('execution');
+        this.sandboxType = execProps('sandboxType', 'none');
+
+        this.packager = new Packager();
+        this.dirPath = 'not initialized';
+    }
+
+    protected async executableGet(hash: string, destinationFolder: string) {
+        const result = await this.environment.executableCache.get(hash);
+        if (!result.hit) return null;
+        const filepath = destinationFolder + '/' + hash;
+        await fs.writeFile(filepath, unwrap(result.data));
+        return filepath;
+    }
+
+    protected async loadPackageWithExecutable(hash: string, dirPath: string): Promise<BuildResult> {
+        const compilationResultFilename = 'compilation-result.json';
+        const startTime = process.hrtime.bigint();
+        const outputFilename = await this.executableGet(hash, dirPath);
+        if (outputFilename) {
+            logger.debug(`Using cached package ${outputFilename}`);
+            await this.packager.unpack(outputFilename, dirPath);
+            const buildResultsBuf = await fs.readFile(path.join(dirPath, compilationResultFilename));
+            const buildResults = JSON.parse(buildResultsBuf.toString('utf8'));
+            // logger.info(hash + ' => ' + JSON.stringify(buildResults));
+            const endTime = process.hrtime.bigint();
+
+            let inputFilename = '';
+            if (buildResults.inputFilename) {
+                inputFilename = path.join(dirPath, path.basename(buildResults.inputFilename));
+            }
+
+            let executableFilename = '';
+            if (buildResults.executableFilename) {
+                const execPath = utils.maskRootdir(buildResults.executableFilename);
+                executableFilename = path.join(dirPath, execPath);
+            }
+
+            return Object.assign({}, buildResults, {
+                code: 0,
+                inputFilename: inputFilename,
+                dirPath: dirPath,
+                executableFilename: executableFilename,
+                packageDownloadAndUnzipTime: ((endTime - startTime) / BigInt(1000000)).toString(),
+            });
+        } else {
+            throw new ExecutablePackageCacheMiss('Tried to get executable from cache, but got a cache miss');
+        }
+    }
+
+    async downloadExecutablePackage(hash: string): Promise<void> {
+        this.dirPath = await temp.mkdir({prefix: utils.ce_temp_prefix, dir: os.tmpdir()});
+
+        this.buildResult = await this.loadPackageWithExecutable(hash, this.dirPath);
+    }
+
+    protected getDefaultExecOptions(params: ExecutionParams): ExecutionOptions & {env: Record<string, string>} {
+        const env: Record<string, string> = {};
+        env.PATH = '';
+
+        if (params.runtimeTools) {
+            const runtimeEnv = params.runtimeTools.find(tool => tool.name === RuntimeToolType.env);
+            if (runtimeEnv) {
+                for (const opt of runtimeEnv.options) {
+                    env[(opt.name = opt.value)];
+                }
+            }
+        }
+
+        // todo: what to do about the rest of the runtimeTools?
+
+        if (
+            this.buildResult &&
+            this.buildResult.defaultExecOptions &&
+            this.buildResult.defaultExecOptions.env &&
+            this.buildResult.defaultExecOptions.env.PATH
+        ) {
+            if (env.PATH.length > 0)
+                env.PATH = env.PATH + path.delimiter + this.buildResult.defaultExecOptions.env.PATH;
+            else env.PATH = this.buildResult.defaultExecOptions.env.PATH;
+        }
+
+        const execOptions: ExecutionOptions & {env: Record<string, string>} = {
+            env,
+        };
+
+        if (this.buildResult && this.buildResult.preparedLdPaths) {
+            execOptions.ldPath = this.buildResult.preparedLdPaths;
+        }
+
+        return execOptions;
+    }
+
+    async execute(params: ExecutionParams): Promise<BasicExecutionResult> {
+        assert(this.buildResult);
+
+        const execExecutableOptions: ExecutableExecutionOptions = {
+            args: typeof params.args === 'string' ? utils.splitArguments(params.args) : params.args || [],
+            stdin: params.stdin || '',
+            ldPath: this.buildResult.preparedLdPaths || [],
+            env: {},
+            runtimeTools: params.runtimeTools,
+        };
+
+        const homeDir = await temp.mkdir({prefix: utils.ce_temp_prefix, dir: os.tmpdir()});
+
+        return await this.execBinary(this.buildResult.executableFilename, execExecutableOptions, homeDir);
+    }
+
+    protected setEnvironmentVariablesFromRuntime(
+        configuredTools: ConfiguredRuntimeTools,
+        execOptions: ExecutionOptions,
+    ) {
+        for (const runtime of configuredTools) {
+            if (runtime.name === RuntimeToolType.env) {
+                for (const env of runtime.options) {
+                    if (!execOptions.env) execOptions.env = {};
+
+                    execOptions.env[env.name] = env.value;
+                }
+            }
+        }
+    }
+
+    async execBinary(
+        executable: string,
+        executeParameters: ExecutableExecutionOptions,
+        homeDir: string,
+        extraConfiguration?: any,
+    ): Promise<BasicExecutionResult> {
+        try {
+            const execOptions: ExecutionOptions = {
+                maxOutput: this.maxExecOutputSize,
+                timeoutMs: this.timeoutMs,
+                ldPath: [...executeParameters.ldPath],
+                input: executeParameters.stdin,
+                customCwd: homeDir,
+                appHome: homeDir,
+            };
+
+            if (this.useSanitizerEnvHints) {
+                execOptions.env = {
+                    ASAN_OPTIONS: 'color=always',
+                    UBSAN_OPTIONS: 'color=always',
+                    MSAN_OPTIONS: 'color=always',
+                    LSAN_OPTIONS: 'color=always',
+                    ...executeParameters.env,
+                };
+            } else {
+                execOptions.env = {
+                    ...executeParameters.env,
+                };
+            }
+
+            return this.execBinaryMaybeWrapped(
+                executable,
+                executeParameters.args,
+                execOptions,
+                executeParameters,
+                homeDir,
+            );
+        } catch (err: UnprocessedExecResult | any) {
+            if (err.code && err.stderr) {
+                return utils.processExecutionResult(err);
+            } else {
+                return {
+                    ...utils.getEmptyExecutionResult(),
+                    stdout: err.stdout ? utils.parseOutput(err.stdout) : [],
+                    stderr: err.stderr ? utils.parseOutput(err.stderr) : [],
+                    code: err.code === undefined ? -1 : err.code,
+                };
+            }
+        }
+    }
+
+    protected async execBinaryMaybeWrapped(
+        executable: string,
+        args: string[],
+        execOptions: ExecutionOptions,
+        executeParameters: ExecutableExecutionOptions,
+        homeDir: string,
+    ): Promise<BasicExecutionResult> {
+        let runWithHeaptrack: ConfiguredRuntimeTool | undefined = undefined;
+
+        if (!execOptions.env) execOptions.env = {};
+
+        if (executeParameters.runtimeTools) {
+            this.setEnvironmentVariablesFromRuntime(executeParameters.runtimeTools, execOptions);
+
+            for (const runtime of executeParameters.runtimeTools) {
+                if (runtime.name === RuntimeToolType.heaptrack) {
+                    runWithHeaptrack = runtime;
+                }
+            }
+        }
+
+        if (runWithHeaptrack && HeaptrackWrapper.isSupported(this.environment)) {
+            const wrapper = new HeaptrackWrapper(
+                homeDir,
+                exec.sandbox,
+                exec.execute,
+                runWithHeaptrack.options,
+                this.environment.ceProps,
+                this.sandboxType,
+            );
+            const execResult: UnprocessedExecResult = await wrapper.exec(executable, args, execOptions);
+            const processed = this.processUserExecutableExecutionResult(execResult, [utils.LineParseOption.AtFileLine]);
+
+            if (executeParameters.runtimeTools) {
+                for (const runtime of executeParameters.runtimeTools) {
+                    if (runtime.name === RuntimeToolType.heaptrack) {
+                        await addHeaptrackResults(processed, homeDir);
+                    }
+                }
+            }
+
+            return processed;
+        } else {
+            const execResult: UnprocessedExecResult = await exec.sandbox(executable, args, execOptions);
+            return this.processUserExecutableExecutionResult(execResult, []);
+        }
+    }
+
+    processUserExecutableExecutionResult(
+        input: UnprocessedExecResult,
+        stdErrlineParseOptions: utils.LineParseOptions,
+    ): BasicExecutionResult {
+        const start = performance.now();
+        const stdout = utils.parseOutput(input.stdout, undefined, undefined, []);
+        const stderr = utils.parseOutput(input.stderr, undefined, undefined, stdErrlineParseOptions);
+        const end = performance.now();
+        return {
+            ...input,
+            stdout,
+            stderr,
+            processExecutionResultTime: end - start,
+        };
+    }
+}
diff --git a/lib/execution/dotnet-execution-env.ts b/lib/execution/dotnet-execution-env.ts
new file mode 100644
index 000000000..ec5ebe2c8
--- /dev/null
+++ b/lib/execution/dotnet-execution-env.ts
@@ -0,0 +1,94 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import path from 'path';
+
+import {BasicExecutionResult, ExecutableExecutionOptions} from '../../types/execution/execution.interfaces.js';
+import * as utils from '../utils.js';
+
+import {LocalExecutionEnvironment} from './base-execution-env.js';
+
+export const AssemblyName = 'CompilerExplorer';
+
+export type DotnetExtraConfiguration = {
+    buildConfig: string;
+    clrBuildDir: string;
+    langVersion: string;
+    targetFramework: string;
+    corerunPath: string;
+};
+
+export class DotnetExecutionEnvironment extends LocalExecutionEnvironment {
+    static override get key() {
+        return 'local-dotnet';
+    }
+
+    override async execBinary(
+        executable: string,
+        executeParameters: ExecutableExecutionOptions,
+        homeDir: string,
+        extraConfiguration: any,
+    ): Promise<BasicExecutionResult> {
+        const programDir = path.dirname(executable);
+        const programOutputPath = path.join(
+            programDir,
+            'bin',
+            extraConfiguration.buildConfig,
+            extraConfiguration.targetFramework,
+        );
+        const programDllPath = path.join(programOutputPath, `${AssemblyName}.dll`);
+        const execOptions = this.getDefaultExecOptions(executeParameters);
+        execOptions.maxOutput = this.maxExecOutputSize;
+        execOptions.timeoutMs = this.timeoutMs;
+        execOptions.ldPath = executeParameters.ldPath;
+        execOptions.customCwd = homeDir;
+        execOptions.appHome = homeDir;
+        execOptions.env = executeParameters.env;
+        execOptions.env.DOTNET_EnableWriteXorExecute = '0';
+        execOptions.env.DOTNET_CLI_HOME = programDir;
+        execOptions.env.CORE_ROOT = extraConfiguration.clrBuildDir;
+        execOptions.input = executeParameters.stdin;
+        const execArgs = ['-p', 'System.Runtime.TieredCompilation=false', programDllPath, ...executeParameters.args];
+        try {
+            return this.execBinaryMaybeWrapped(
+                extraConfiguration.corerunPath,
+                execArgs,
+                execOptions,
+                executeParameters,
+                homeDir,
+            );
+        } catch (err: any) {
+            if (err.code && err.stderr) {
+                return utils.processExecutionResult(err);
+            } else {
+                return {
+                    ...utils.getEmptyExecutionResult(),
+                    stdout: err.stdout ? utils.parseOutput(err.stdout) : [],
+                    stderr: err.stderr ? utils.parseOutput(err.stderr) : [],
+                    code: err.code === undefined ? -1 : err.code,
+                };
+            }
+        }
+    }
+}
diff --git a/lib/execution/execution-env.interfaces.ts b/lib/execution/execution-env.interfaces.ts
new file mode 100644
index 000000000..8e493c03f
--- /dev/null
+++ b/lib/execution/execution-env.interfaces.ts
@@ -0,0 +1,39 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import {ExecutionParams} from '../../types/compilation/compilation.interfaces.js';
+import {BasicExecutionResult, ExecutableExecutionOptions} from '../../types/execution/execution.interfaces.js';
+
+export interface IExecutionEnvironment {
+    downloadExecutablePackage(hash: string): Promise<void>;
+    execute(params: ExecutionParams): Promise<BasicExecutionResult>;
+    execBinary(
+        executable: string,
+        executeParameters: ExecutableExecutionOptions,
+        homeDir: string,
+        extraConfiguration?: any,
+    ): Promise<BasicExecutionResult>;
+}
+
+export class ExecutablePackageCacheMiss extends Error {}
diff --git a/lib/execution/index.ts b/lib/execution/index.ts
new file mode 100644
index 000000000..5506f58d5
--- /dev/null
+++ b/lib/execution/index.ts
@@ -0,0 +1,31 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import {makeKeyedTypeGetter} from '../keyed-type.js';
+
+import * as all from './_all.js';
+
+export * from './_all.js';
+
+export const getExecutionEnvironmentByKey = makeKeyedTypeGetter('executionEnvironment', all);
diff --git a/lib/handlers/api.ts b/lib/handlers/api.ts
index c64f8c269..1e610b485 100644
--- a/lib/handlers/api.ts
+++ b/lib/handlers/api.ts
@@ -31,6 +31,9 @@ import {CompilerInfo} from '../../types/compiler.interfaces.js';
 import {Language, LanguageKey} from '../../types/languages.interfaces.js';
 import {assert, unwrap} from '../assert.js';
 import {ClientStateNormalizer} from '../clientstate-normalizer.js';
+import {CompilationEnvironment} from '../compilation-env.js';
+import {LocalExecutionEnvironment} from '../execution/base-execution-env.js';
+import {IExecutionEnvironment} from '../execution/execution-env.interfaces.js';
 import {logger} from '../logger.js';
 import {ClientOptionsHandler} from '../options-handler.js';
 import {PropertyGetter} from '../properties.interfaces.js';
@@ -60,14 +63,17 @@ export class ApiHandler {
         gitReleaseName: '',
         releaseBuildNumber: '',
     };
+    private readonly compilationEnvironment: CompilationEnvironment;
 
     constructor(
         compileHandler: CompileHandler,
         ceProps: PropertyGetter,
         private readonly storageHandler: StorageBase,
         urlShortenService: string,
+        compilationEnvironment: CompilationEnvironment,
     ) {
         this.handle = express.Router();
+        this.compilationEnvironment = compilationEnvironment;
         const cacheHeader = `public, max-age=${ceProps('apiMaxAgeSecs', 24 * 60 * 60)}`;
         this.handle.use((req, res, next) => {
             res.header({
@@ -107,6 +113,10 @@ export class ApiHandler {
             .post(compileHandler.handleCmake.bind(compileHandler))
             .all(methodNotAllowed);
 
+        if (this.compilationEnvironment.ceProps('localexecutionEndpoint', false)) {
+            this.handle.route('/localexecution/:hash').post(this.handleLocalExecution.bind(this)).all(methodNotAllowed);
+        }
+
         this.handle
             .route('/popularArguments/:compiler')
             .post(compileHandler.handlePopularArguments.bind(compileHandler))
@@ -277,6 +287,29 @@ export class ApiHandler {
         }
     }
 
+    async handleLocalExecution(req: express.Request, res: express.Response, next: express.NextFunction) {
+        if (!req.params.hash) {
+            next({statusCode: 404, message: 'No hash supplied'});
+            return;
+        }
+
+        if (!req.body.ExecutionParams) {
+            next({statusCode: 404, message: 'No ExecutionParams'});
+            return;
+        }
+
+        try {
+            const env: IExecutionEnvironment = new LocalExecutionEnvironment(this.compilationEnvironment);
+            await env.downloadExecutablePackage(req.params.hash);
+            const execResult = await env.execute(req.body.ExecutionParams);
+            logger.debug('execResult', execResult);
+            res.send(execResult);
+        } catch (e) {
+            logger.error(e);
+            next({statusCode: 500, message: 'Internal error'});
+        }
+    }
+
     handleAllLibraries(req: express.Request, res: express.Response, next: express.NextFunction) {
         if (this.options) {
             res.send(this.options.options.libs);
diff --git a/lib/handlers/route-api.ts b/lib/handlers/route-api.ts
index fb58fe76f..004521946 100644
--- a/lib/handlers/route-api.ts
+++ b/lib/handlers/route-api.ts
@@ -29,6 +29,7 @@ import {isString} from '../../shared/common-utils.js';
 import {assert, unwrap} from '../assert.js';
 import {ClientStateGoldenifier, ClientStateNormalizer} from '../clientstate-normalizer.js';
 import {ClientState} from '../clientstate.js';
+import {CompilationEnvironment} from '../compilation-env.js';
 import {logger} from '../logger.js';
 import {ClientOptionsHandler} from '../options-handler.js';
 import {PropertyGetter} from '../properties.interfaces.js';
@@ -51,6 +52,7 @@ export type HandlerConfig = {
     renderGoldenLayout: any;
     staticHeaders: any;
     contentPolicyHeader: any;
+    compilationEnvironment: CompilationEnvironment;
 };
 
 type ShortLinkMetaData = {
@@ -80,6 +82,7 @@ export class RouteAPI {
                 config.ceProps,
                 config.storageHandler,
                 config.clientOptionsHandler.options.urlShortenService,
+                config.compilationEnvironment,
             );
 
             this.apiHandler.setReleaseInfo(config.defArgs.gitReleaseName, config.defArgs.releaseBuildNumber);
diff --git a/lib/utils.ts b/lib/utils.ts
index e8c94d670..39358cf1d 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -34,6 +34,7 @@ import {parse as quoteParse} from 'shell-quote';
 import _ from 'underscore';
 
 import type {CacheableValue} from '../types/cache.interfaces.js';
+import {BasicExecutionResult, UnprocessedExecResult} from '../types/execution/execution.interfaces.js';
 import type {ResultLine} from '../types/resultline/resultline.interfaces.js';
 
 const tabsRe = /\t/g;
@@ -541,3 +542,28 @@ export function asSafeVer(semver: string | number | null | undefined): string {
     }
     return magic_semver.non_trunk;
 }
+
+export function processExecutionResult(input: UnprocessedExecResult, inputFilename?: string): BasicExecutionResult {
+    const start = performance.now();
+    const stdout = parseOutput(input.stdout, inputFilename);
+    const stderr = parseOutput(input.stderr, inputFilename);
+    const end = performance.now();
+    return {
+        ...input,
+        stdout,
+        stderr,
+        processExecutionResultTime: end - start,
+    };
+}
+
+export function getEmptyExecutionResult(): BasicExecutionResult {
+    return {
+        code: -1,
+        okToCache: false,
+        filenameTransform: x => x,
+        stdout: [],
+        stderr: [],
+        execTime: '',
+        timedOut: false,
+    };
+}
diff --git a/static/panes/diff.ts b/static/panes/diff.ts
index da0f57711..6ddba55d9 100644
--- a/static/panes/diff.ts
+++ b/static/panes/diff.ts
@@ -74,7 +74,7 @@ class DiffStateObject {
     id?: number | string;
     model: monaco.editor.ITextModel;
     compiler: CompilerEntry | null;
-    result: CompilationResult | null;
+    result?: CompilationResult;
     difftype: DiffType;
     extraoption?: string;
 
@@ -87,7 +87,7 @@ class DiffStateObject {
         this.id = id;
         this.model = model;
         this.compiler = null;
-        this.result = null;
+        this.result = undefined;
         this.difftype = difftype;
         this.extraoption = extraoption;
     }
@@ -115,10 +115,10 @@ class DiffStateObject {
                     output = this.result.stderr;
                     break;
                 case DiffType.ExecStdOut:
-                    if (this.result.execResult) output = this.result.execResult.stdout || [];
+                    if (this.result.execResult) output = this.result.execResult.stdout;
                     break;
                 case DiffType.ExecStdErr:
-                    if (this.result.execResult) output = this.result.execResult.stderr || [];
+                    if (this.result.execResult) output = this.result.execResult.stderr;
                     break;
                 case DiffType.GNAT_ExpandedCode:
                     output = this.result.gnatDebugOutput || [];
diff --git a/static/panes/executor.ts b/static/panes/executor.ts
index a3a536f58..7a8cc76cd 100644
--- a/static/panes/executor.ts
+++ b/static/panes/executor.ts
@@ -559,7 +559,7 @@ export class Executor extends Pane<ExecutorState> {
     }
 
     getExecutionStdoutfromResult(result: CompilationResult): ResultLine[] {
-        if (result.execResult && result.execResult.stdout !== undefined) {
+        if (result.execResult) {
             return result.execResult.stdout;
         }
 
@@ -569,7 +569,7 @@ export class Executor extends Pane<ExecutorState> {
 
     getExecutionStderrfromResult(result: CompilationResult): ResultLine[] {
         if (result.execResult) {
-            return result.execResult.stderr as ResultLine[];
+            return result.execResult.stderr;
         }
 
         // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -702,6 +702,8 @@ export class Executor extends Pane<ExecutorState> {
                     this.offerViewInSpeedscope(artifact);
                 }
             }
+        } else if (result.execResult) {
+            this.offerFilesIfPossible(result.execResult);
         }
     }
 
diff --git a/static/panes/output.ts b/static/panes/output.ts
index 92d4d531e..4967b4e5b 100644
--- a/static/panes/output.ts
+++ b/static/panes/output.ts
@@ -222,8 +222,8 @@ export class Output extends Pane<OutputState> {
 
         if (result.execResult && (result.execResult.didExecute || result.didExecute)) {
             this.add('Program returned: ' + result.execResult.code);
-            if (result.execResult.stderr?.length || result.execResult.stdout?.length) {
-                for (const obj of result.execResult.stderr ?? []) {
+            if (result.execResult.stderr.length || result.execResult.stdout.length) {
+                for (const obj of result.execResult.stderr) {
                     // Conserve empty lines as they are discarded by ansiToHtml
                     if (obj.text === '') {
                         this.programOutput('<br/>');
@@ -232,7 +232,7 @@ export class Output extends Pane<OutputState> {
                     }
                 }
 
-                for (const obj of result.execResult.stdout ?? []) {
+                for (const obj of result.execResult.stdout) {
                     // Conserve empty lines as they are discarded by ansiToHtml
                     if (obj.text === '') {
                         this.programOutput('<br/>');
diff --git a/types/compilation/compilation.interfaces.ts b/types/compilation/compilation.interfaces.ts
index 4defbbaa1..81fbb3b0a 100644
--- a/types/compilation/compilation.interfaces.ts
+++ b/types/compilation/compilation.interfaces.ts
@@ -150,14 +150,8 @@ export type CompilationResult = {
     stderr: ResultLine[];
     truncated?: boolean;
     didExecute?: boolean;
-    execResult?: {
-        stdout?: ResultLine[];
-        stderr?: ResultLine[];
-        code: number;
-        didExecute: boolean;
-        buildResult?: BuildResult;
-        execTime?: number;
-    };
+    executableFilename?: string;
+    execResult?: CompilationResult;
     gnatDebugOutput?: ResultLine[];
     gnatDebugTreeOutput?: ResultLine[];
     tools?: ToolResult[];
@@ -236,6 +230,8 @@ export type BuildResult = CompilationResult & {
     downloads: BuildEnvDownloadInfo[];
     executableFilename: string;
     compilationOptions: string[];
+    preparedLdPaths?: string[];
+    defaultExecOptions?: ExecutionOptions;
     stdout: ResultLine[];
     stderr: ResultLine[];
     code: number;


Test patch:
diff --git a/test/demangler-tests.ts b/test/demangler-tests.ts
index d6b81f33e..f225cfefd 100644
--- a/test/demangler-tests.ts
+++ b/test/demangler-tests.ts
@@ -42,7 +42,9 @@ class DummyCompiler extends BaseCompiler {
     constructor() {
         const env = {
             ceProps: properties.fakeProps({}),
-            compilerProps: () => {},
+            getCompilerPropsForLanguage: () => {
+                return (prop, def) => def;
+            },
         } as unknown as CompilationEnvironment;
 
         // using c++ as the compiler needs at least one language
diff --git a/test/handlers/api-tests.ts b/test/handlers/api-tests.ts
index 9cb717fc0..e809a25f6 100644
--- a/test/handlers/api-tests.ts
+++ b/test/handlers/api-tests.ts
@@ -27,6 +27,7 @@ import express from 'express';
 import request from 'supertest';
 import {beforeAll, describe, expect, it} from 'vitest';
 
+import {CompilationEnvironment} from '../../lib/compilation-env.js';
 import {ApiHandler} from '../../lib/handlers/api.js';
 import {CompileHandler} from '../../lib/handlers/compile.js';
 import {CompilerProps, fakeProps} from '../../lib/properties.js';
@@ -94,6 +95,7 @@ describe('API handling', () => {
             }),
             new StorageNull('/', new CompilerProps(languages, fakeProps({}))),
             'default',
+            {ceProps: (key, def) => def} as CompilationEnvironment,
         );
         app.use(bodyParser.json());
         app.use('/api', apiHandler.handle);
diff --git a/test/options-handler.ts b/test/options-handler.ts
index 1bdabbc34..07c572784 100644
--- a/test/options-handler.ts
+++ b/test/options-handler.ts
@@ -180,6 +180,9 @@ describe('Options handler', () => {
         env = {
             ceProps: properties.fakeProps({}),
             compilerProps: () => {},
+            getCompilerPropsForLanguage: () => {
+                return (prop, def) => def;
+            },
         } as unknown as CompilationEnvironment;
     });
 
@@ -489,6 +492,9 @@ describe('Options handler', () => {
         const env = {
             ceProps: properties.fakeProps({}),
             compilerProps: () => {},
+            getCompilerPropsForLanguage: () => {
+                return (prop, def) => def;
+            },
         } as unknown as CompilationEnvironment;
 
         const compiler = new BaseCompiler(compilerInfo, env);
diff --git a/test/pp-output-test.ts b/test/pp-output-test.ts
index 091286ede..86851dbe2 100644
--- a/test/pp-output-test.ts
+++ b/test/pp-output-test.ts
@@ -49,6 +49,9 @@ describe('Preprocessor Output Handling', () => {
         const env: Partial<CompilationEnvironment> = {
             ceProps: properties.fakeProps({}),
             compilerProps: (() => {}) as unknown as any,
+            getCompilerPropsForLanguage: () => {
+                return (prop, def) => def;
+            },
         };
         const compiler = new BaseCompiler(compilerInfo as CompilerInfo, env as CompilationEnvironment);
         for (const testCase of cases) {