sea: add support for V8 bytecode-only caching

Refs: https://github.com/nodejs/single-executable/issues/73
Signed-off-by: Darshan Sen <raisinten@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/48191
Fixes: https://github.com/nodejs/single-executable/issues/73
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Stephen Belanger <admin@stephenbelanger.com>
Reviewed-By: Joyee Cheung <joyeec9h3@gmail.com>
This commit is contained in:
Darshan Sen 2023-07-26 15:40:35 +05:30 committed by GitHub
parent d246536924
commit 6cd678965f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 347 additions and 41 deletions

View File

@ -10,6 +10,9 @@ changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/46824
description: Added support for "useSnapshot".
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/48191
description: Added support for "useCodeCache".
-->
> Stability: 1 - Experimental: This feature is being designed and will change.
@ -174,7 +177,8 @@ The configuration currently reads the following top-level fields:
"main": "/path/to/bundled/script.js",
"output": "/path/to/write/the/generated/blob.blob",
"disableExperimentalSEAWarning": true, // Default: false
"useSnapshot": false // Default: false
"useSnapshot": false, // Default: false
"useCodeCache": true // Default: false
}
```
@ -213,6 +217,18 @@ and the main script can use the [`v8.startupSnapshot` API][] to adapt to
these constraints. See
[documentation about startup snapshot support in Node.js][].
### V8 code cache support
When `useCodeCache` is set to `true` in the configuration, during the generation
of the single executable preparation blob, Node.js will compile the `main`
script to generate the V8 code cache. The generated code cache would be part of
the preparation blob and get injected into the final executable. When the single
executable application is launched, instead of compiling the `main` script from
scratch, Node.js would use the code cache to speed up the compilation, then
execute the script, which would improve the startup performance.
**Note:** `import()` does not work when `useCodeCache` is `true`.
## Notes
### `require(id)` in the injected module is not file based

View File

@ -1123,7 +1123,7 @@ Module.prototype.require = function(id) {
let resolvedArgv;
let hasPausedEntry = false;
let Script;
function wrapSafe(filename, content, cjsModuleInstance) {
function wrapSafe(filename, content, cjsModuleInstance, codeCache) {
if (patched) {
const wrapper = Module.wrap(content);
if (Script === undefined) {
@ -1158,6 +1158,7 @@ function wrapSafe(filename, content, cjsModuleInstance) {
'__dirname',
], {
filename,
cachedData: codeCache,
importModuleDynamically(specifier, _, importAssertions) {
const cascadedLoader = getCascadedLoader();
return cascadedLoader.import(specifier, normalizeReferrerURL(filename),
@ -1165,6 +1166,13 @@ function wrapSafe(filename, content, cjsModuleInstance) {
},
});
// The code cache is used for SEAs only.
if (codeCache &&
result.cachedDataRejected !== false &&
internalBinding('sea').isSea()) {
process.emitWarning('Code cache data rejected.');
}
// Cache the source map for the module if present.
if (result.sourceMapURL) {
maybeCacheSourceMap(filename, content, this, false, undefined, result.sourceMapURL);

View File

@ -1,7 +1,8 @@
'use strict';
const { codes: { ERR_UNKNOWN_BUILTIN_MODULE } } = require('internal/errors');
const { BuiltinModule: { normalizeRequirableId } } = require('internal/bootstrap/realm');
const { Module, wrapSafe } = require('internal/modules/cjs/loader');
const { codes: { ERR_UNKNOWN_BUILTIN_MODULE } } = require('internal/errors');
const { getCodeCache, getCodePath, isSea } = internalBinding('sea');
// This is roughly the same as:
//
@ -15,7 +16,11 @@ const { Module, wrapSafe } = require('internal/modules/cjs/loader');
function embedderRunCjs(contents) {
const filename = process.execPath;
const compiledWrapper = wrapSafe(filename, contents);
const compiledWrapper = wrapSafe(
isSea() ? getCodePath() : filename,
contents,
undefined,
getCodeCache());
const customModule = new Module(filename, null);
customModule.filename = filename;

View File

@ -4,7 +4,6 @@
#include "util-inl.h"
namespace node {
using v8::ArrayBuffer;
using v8::Context;
using v8::Isolate;
using v8::Local;
@ -12,26 +11,8 @@ using v8::Object;
using v8::String;
using v8::Value;
static Isolate* NewIsolate(v8::ArrayBuffer::Allocator* allocator) {
Isolate* isolate = Isolate::Allocate();
CHECK_NOT_NULL(isolate);
per_process::v8_platform.Platform()->RegisterIsolate(isolate,
uv_default_loop());
Isolate::CreateParams params;
params.array_buffer_allocator = allocator;
Isolate::Initialize(isolate, params);
return isolate;
}
void JSONParser::FreeIsolate(Isolate* isolate) {
per_process::v8_platform.Platform()->UnregisterIsolate(isolate);
isolate->Dispose();
}
JSONParser::JSONParser()
: allocator_(ArrayBuffer::Allocator::NewDefaultAllocator()),
isolate_(NewIsolate(allocator_.get())),
handle_scope_(isolate_.get()),
: handle_scope_(isolate_.get()),
context_(isolate_.get(), Context::New(isolate_.get())),
context_scope_(context_.Get(isolate_.get())) {}

View File

@ -24,9 +24,7 @@ class JSONParser {
private:
// We might want a lighter-weight JSON parser for this use case. But for now
// using V8 is good enough.
static void FreeIsolate(v8::Isolate* isolate);
std::unique_ptr<v8::ArrayBuffer::Allocator> allocator_;
DeleteFnPtr<v8::Isolate, FreeIsolate> isolate_;
RAIIIsolate isolate_;
v8::HandleScope handle_scope_;
v8::Global<v8::Context> context_;
v8::Context::Scope context_scope_;

View File

@ -935,6 +935,22 @@ Maybe<bool> StoreCodeCacheResult(
return Just(true);
}
// TODO(RaisinTen): Reuse in ContextifyContext::CompileFunction().
MaybeLocal<Function> CompileFunction(Local<Context> context,
Local<String> filename,
Local<String> content,
std::vector<Local<String>>* parameters) {
ScriptOrigin script_origin(context->GetIsolate(), filename, 0, 0, true);
ScriptCompiler::Source script_source(content, script_origin);
return ScriptCompiler::CompileFunction(context,
&script_source,
parameters->size(),
parameters->data(),
0,
nullptr);
}
bool ContextifyScript::InstanceOf(Environment* env,
const Local<Value>& value) {
return !value.IsEmpty() &&

View File

@ -210,6 +210,12 @@ v8::Maybe<bool> StoreCodeCacheResult(
bool produce_cached_data,
std::unique_ptr<v8::ScriptCompiler::CachedData> new_cached_data);
v8::MaybeLocal<v8::Function> CompileFunction(
v8::Local<v8::Context> context,
v8::Local<v8::String> filename,
v8::Local<v8::String> content,
std::vector<v8::Local<v8::String>>* parameters);
} // namespace contextify
} // namespace node

View File

@ -4,11 +4,14 @@
#include "debug_utils-inl.h"
#include "env-inl.h"
#include "json_parser.h"
#include "node_contextify.h"
#include "node_errors.h"
#include "node_external_reference.h"
#include "node_internals.h"
#include "node_snapshot_builder.h"
#include "node_union_bytes.h"
#include "node_v8_platform-inl.h"
#include "util-inl.h"
// The POSTJECT_SENTINEL_FUSE macro is a string of random characters selected by
// the Node.js project that is present only once in the entire binary. It is
@ -27,10 +30,19 @@
#if !defined(DISABLE_SINGLE_EXECUTABLE_APPLICATION)
using node::ExitCode;
using v8::ArrayBuffer;
using v8::BackingStore;
using v8::Context;
using v8::DataView;
using v8::Function;
using v8::FunctionCallbackInfo;
using v8::HandleScope;
using v8::Isolate;
using v8::Local;
using v8::NewStringType;
using v8::Object;
using v8::ScriptCompiler;
using v8::String;
using v8::Value;
namespace node {
@ -76,6 +88,12 @@ size_t SeaSerializer::Write(const SeaResource& sea) {
written_total += WriteArithmetic<uint32_t>(flags);
DCHECK_EQ(written_total, SeaResource::kHeaderSize);
Debug("Write SEA code path %p, size=%zu\n",
sea.code_path.data(),
sea.code_path.size());
written_total +=
WriteStringView(sea.code_path, StringLogMode::kAddressAndContent);
Debug("Write SEA resource %s %p, size=%zu\n",
sea.use_snapshot() ? "snapshot" : "code",
sea.main_code_or_snapshot.data(),
@ -84,6 +102,14 @@ size_t SeaSerializer::Write(const SeaResource& sea) {
WriteStringView(sea.main_code_or_snapshot,
sea.use_snapshot() ? StringLogMode::kAddressOnly
: StringLogMode::kAddressAndContent);
if (sea.code_cache.has_value()) {
Debug("Write SEA resource code cache %p, size=%zu\n",
sea.code_cache->data(),
sea.code_cache->size());
written_total +=
WriteStringView(sea.code_cache.value(), StringLogMode::kAddressOnly);
}
return written_total;
}
@ -109,6 +135,11 @@ SeaResource SeaDeserializer::Read() {
Debug("Read SEA flags %x\n", static_cast<uint32_t>(flags));
CHECK_EQ(read_total, SeaResource::kHeaderSize);
std::string_view code_path =
ReadStringView(StringLogMode::kAddressAndContent);
Debug(
"Read SEA code path %p, size=%zu\n", code_path.data(), code_path.size());
bool use_snapshot = static_cast<bool>(flags & SeaFlags::kUseSnapshot);
std::string_view code =
ReadStringView(use_snapshot ? StringLogMode::kAddressOnly
@ -118,7 +149,15 @@ SeaResource SeaDeserializer::Read() {
use_snapshot ? "snapshot" : "code",
code.data(),
code.size());
return {flags, code};
std::string_view code_cache;
if (static_cast<bool>(flags & SeaFlags::kUseCodeCache)) {
code_cache = ReadStringView(StringLogMode::kAddressOnly);
Debug("Read SEA resource code cache %p, size=%zu\n",
code_cache.data(),
code_cache.size());
}
return {flags, code_path, code, code_cache};
}
std::string_view FindSingleExecutableBlob() {
@ -167,6 +206,10 @@ bool IsSingleExecutable() {
return postject_has_resource();
}
void IsSea(const FunctionCallbackInfo<Value>& args) {
args.GetReturnValue().Set(IsSingleExecutable());
}
void IsExperimentalSeaWarningNeeded(const FunctionCallbackInfo<Value>& args) {
bool is_building_sea =
!per_process::cli_options->experimental_sea_config.empty();
@ -185,6 +228,54 @@ void IsExperimentalSeaWarningNeeded(const FunctionCallbackInfo<Value>& args) {
sea_resource.flags & SeaFlags::kDisableExperimentalSeaWarning));
}
void GetCodeCache(const FunctionCallbackInfo<Value>& args) {
if (!IsSingleExecutable()) {
return;
}
Isolate* isolate = args.GetIsolate();
SeaResource sea_resource = FindSingleExecutableResource();
if (!static_cast<bool>(sea_resource.flags & SeaFlags::kUseCodeCache)) {
return;
}
std::shared_ptr<BackingStore> backing_store = ArrayBuffer::NewBackingStore(
const_cast<void*>(
static_cast<const void*>(sea_resource.code_cache->data())),
sea_resource.code_cache->length(),
[](void* /* data */, size_t /* length */, void* /* deleter_data */) {
// The code cache data blob is not freed here because it is a static
// blob which is not allocated by the BackingStore allocator.
},
nullptr);
Local<ArrayBuffer> array_buffer = ArrayBuffer::New(isolate, backing_store);
Local<DataView> data_view =
DataView::New(array_buffer, 0, array_buffer->ByteLength());
args.GetReturnValue().Set(data_view);
}
void GetCodePath(const FunctionCallbackInfo<Value>& args) {
DCHECK(IsSingleExecutable());
Isolate* isolate = args.GetIsolate();
SeaResource sea_resource = FindSingleExecutableResource();
Local<String> code_path;
if (!String::NewFromUtf8(isolate,
sea_resource.code_path.data(),
NewStringType::kNormal,
sea_resource.code_path.length())
.ToLocal(&code_path)) {
return;
}
args.GetReturnValue().Set(code_path);
}
std::tuple<int, char**> FixupArgsForSEA(int argc, char** argv) {
// Repeats argv[0] at position 1 on argv as a replacement for the missing
// entry point file path.
@ -269,6 +360,17 @@ std::optional<SeaConfig> ParseSingleExecutableConfig(
result.flags |= SeaFlags::kUseSnapshot;
}
std::optional<bool> use_code_cache =
parser.GetTopLevelBoolField("useCodeCache");
if (!use_code_cache.has_value()) {
FPrintF(
stderr, "\"useCodeCache\" field of %s is not a Boolean\n", config_path);
return std::nullopt;
}
if (use_code_cache.value()) {
result.flags |= SeaFlags::kUseCodeCache;
}
return result;
}
@ -307,6 +409,59 @@ ExitCode GenerateSnapshotForSEA(const SeaConfig& config,
return ExitCode::kNoFailure;
}
std::optional<std::string> GenerateCodeCache(std::string_view main_path,
std::string_view main_script) {
RAIIIsolate raii_isolate;
Isolate* isolate = raii_isolate.get();
HandleScope handle_scope(isolate);
Local<Context> context = Context::New(isolate);
Context::Scope context_scope(context);
errors::PrinterTryCatch bootstrapCatch(
isolate, errors::PrinterTryCatch::kPrintSourceLine);
Local<String> filename;
if (!String::NewFromUtf8(isolate,
main_path.data(),
NewStringType::kNormal,
main_path.length())
.ToLocal(&filename)) {
return std::nullopt;
}
Local<String> content;
if (!String::NewFromUtf8(isolate,
main_script.data(),
NewStringType::kNormal,
main_script.length())
.ToLocal(&content)) {
return std::nullopt;
}
std::vector<Local<String>> parameters = {
FIXED_ONE_BYTE_STRING(isolate, "exports"),
FIXED_ONE_BYTE_STRING(isolate, "require"),
FIXED_ONE_BYTE_STRING(isolate, "module"),
FIXED_ONE_BYTE_STRING(isolate, "__filename"),
FIXED_ONE_BYTE_STRING(isolate, "__dirname"),
};
// TODO(RaisinTen): Using the V8 code cache prevents us from using `import()`
// in the SEA code. Support it.
// Refs: https://github.com/nodejs/node/pull/48191#discussion_r1213271430
Local<Function> fn;
if (!contextify::CompileFunction(context, filename, content, &parameters)
.ToLocal(&fn)) {
return std::nullopt;
}
std::unique_ptr<ScriptCompiler::CachedData> cache{
ScriptCompiler::CreateCodeCacheForFunction(fn)};
std::string code_cache(cache->data, cache->data + cache->length);
return code_cache;
}
ExitCode GenerateSingleExecutableBlob(
const SeaConfig& config,
const std::vector<std::string>& args,
@ -331,11 +486,33 @@ ExitCode GenerateSingleExecutableBlob(
}
}
std::optional<std::string> optional_code_cache =
GenerateCodeCache(config.main_path, main_script);
if (!optional_code_cache.has_value()) {
FPrintF(stderr, "Cannot generate V8 code cache\n");
return ExitCode::kGenericUserError;
}
std::optional<std::string_view> optional_sv_code_cache;
std::string code_cache;
if (static_cast<bool>(config.flags & SeaFlags::kUseCodeCache)) {
std::optional<std::string> optional_code_cache =
GenerateCodeCache(config.main_path, main_script);
if (!optional_code_cache.has_value()) {
FPrintF(stderr, "Cannot generate V8 code cache\n");
return ExitCode::kGenericUserError;
}
code_cache = optional_code_cache.value();
optional_sv_code_cache = code_cache;
}
SeaResource sea{
config.flags,
config.main_path,
builds_snapshot_from_main
? std::string_view{snapshot_blob.data(), snapshot_blob.size()}
: std::string_view{main_script.data(), main_script.size()}};
: std::string_view{main_script.data(), main_script.size()},
optional_sv_code_cache};
SeaSerializer serializer;
serializer.Write(sea);
@ -374,14 +551,20 @@ void Initialize(Local<Object> target,
Local<Value> unused,
Local<Context> context,
void* priv) {
SetMethod(context, target, "isSea", IsSea);
SetMethod(context,
target,
"isExperimentalSeaWarningNeeded",
IsExperimentalSeaWarningNeeded);
SetMethod(context, target, "getCodePath", GetCodePath);
SetMethod(context, target, "getCodeCache", GetCodeCache);
}
void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(IsSea);
registry->Register(IsExperimentalSeaWarningNeeded);
registry->Register(GetCodePath);
registry->Register(GetCodeCache);
}
} // namespace sea

View File

@ -6,10 +6,12 @@
#if !defined(DISABLE_SINGLE_EXECUTABLE_APPLICATION)
#include <cinttypes>
#include <optional>
#include <string>
#include <string_view>
#include <tuple>
#include <vector>
#include "node_exit_code.h"
namespace node {
@ -24,11 +26,14 @@ enum class SeaFlags : uint32_t {
kDefault = 0,
kDisableExperimentalSeaWarning = 1 << 0,
kUseSnapshot = 1 << 1,
kUseCodeCache = 1 << 2,
};
struct SeaResource {
SeaFlags flags = SeaFlags::kDefault;
std::string_view code_path;
std::string_view main_code_or_snapshot;
std::optional<std::string_view> code_cache;
bool use_snapshot() const;
static constexpr size_t kHeaderSize = sizeof(kMagic) + sizeof(SeaFlags);

View File

@ -42,7 +42,6 @@ using v8::MaybeLocal;
using v8::Object;
using v8::ObjectTemplate;
using v8::ScriptCompiler;
using v8::ScriptOrigin;
using v8::SnapshotCreator;
using v8::StartupData;
using v8::String;
@ -1261,7 +1260,6 @@ void CompileSerializeMain(const FunctionCallbackInfo<Value>& args) {
Local<String> source = args[1].As<String>();
Isolate* isolate = args.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
ScriptOrigin origin(isolate, filename, 0, 0, true);
// TODO(joyeecheung): do we need all of these? Maybe we would want a less
// internal version of them.
std::vector<Local<String>> parameters = {
@ -1269,15 +1267,8 @@ void CompileSerializeMain(const FunctionCallbackInfo<Value>& args) {
FIXED_ONE_BYTE_STRING(isolate, "__filename"),
FIXED_ONE_BYTE_STRING(isolate, "__dirname"),
};
ScriptCompiler::Source script_source(source, origin);
Local<Function> fn;
if (ScriptCompiler::CompileFunction(context,
&script_source,
parameters.size(),
parameters.data(),
0,
nullptr,
ScriptCompiler::kNoCompileOptions)
if (contextify::CompileFunction(context, filename, source, &parameters)
.ToLocal(&fn)) {
args.GetReturnValue().Set(fn);
}

View File

@ -28,6 +28,7 @@
#include "node_errors.h"
#include "node_internals.h"
#include "node_util.h"
#include "node_v8_platform-inl.h"
#include "string_bytes.h"
#include "uv.h"
@ -55,6 +56,7 @@ static std::atomic_int seq = {0}; // Sequence number for diagnostic filenames.
namespace node {
using v8::ArrayBuffer;
using v8::ArrayBufferView;
using v8::Context;
using v8::FunctionTemplate;
@ -629,4 +631,20 @@ Local<String> UnionBytes::ToStringChecked(Isolate* isolate) const {
}
}
RAIIIsolate::RAIIIsolate()
: allocator_{ArrayBuffer::Allocator::NewDefaultAllocator()} {
isolate_ = Isolate::Allocate();
CHECK_NOT_NULL(isolate_);
per_process::v8_platform.Platform()->RegisterIsolate(isolate_,
uv_default_loop());
Isolate::CreateParams params;
params.array_buffer_allocator = allocator_.get();
Isolate::Initialize(isolate_, params);
}
RAIIIsolate::~RAIIIsolate() {
per_process::v8_platform.Platform()->UnregisterIsolate(isolate_);
isolate_->Dispose();
}
} // namespace node

View File

@ -958,6 +958,19 @@ void SetConstructorFunction(v8::Isolate* isolate,
SetConstructorFunctionFlag flag =
SetConstructorFunctionFlag::SET_CLASS_NAME);
// Simple RAII class to spin up a v8::Isolate instance.
class RAIIIsolate {
public:
RAIIIsolate();
~RAIIIsolate();
v8::Isolate* get() const { return isolate_; }
private:
std::unique_ptr<v8::ArrayBuffer::Allocator> allocator_;
v8::Isolate* isolate_;
};
} // namespace node
#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS

View File

@ -4,8 +4,8 @@ throw new Error('Should include grayed stack trace')
Error: Should include grayed stack trace
at Object.<anonymous> (/test*force_colors.js:1:7)
 at Module._compile (node:internal*modules*cjs*loader:1233:14)
 at Module._extensions..js (node:internal*modules*cjs*loader:1287:10)
 at Module._compile (node:internal*modules*cjs*loader:1241:14)
 at Module._extensions..js (node:internal*modules*cjs*loader:1295:10)
 at Module.load (node:internal*modules*cjs*loader:1091:32)
 at Module._load (node:internal*modules*cjs*loader:938:12)
 at Function.executeUserEntryPoint [as runMain] (node:internal*modules*run_main:83:12)

View File

@ -5,12 +5,15 @@ const createdRequire = createRequire(__filename);
// because we set NODE_TEST_DIR=/Users/iojs/node-tmp on Jenkins CI.
const { expectWarning, mustNotCall } = createdRequire(process.env.COMMON_DIRECTORY);
// This additionally makes sure that no unexpected warnings are emitted.
if (createdRequire('./sea-config.json').disableExperimentalSEAWarning) {
process.on('warning', mustNotCall());
} else {
expectWarning('ExperimentalWarning',
'Single executable application is an experimental feature and ' +
'might change at any time');
// Any unexpected warning would throw this error:
// https://github.com/nodejs/node/blob/c301404105a7256b79a0b8c4522ce47af96dfa17/test/common/index.js#L697-L700.
}
// Should be possible to require core modules that optionally require the
@ -18,6 +21,9 @@ if (createdRequire('./sea-config.json').disableExperimentalSEAWarning) {
const { deepStrictEqual, strictEqual, throws } = require('assert');
const { dirname } = require('node:path');
// Checks that the source filename is used in the error stack trace.
strictEqual(new Error('lol').stack.split('\n')[1], ' at sea.js:25:13');
// Should be possible to require a core module that requires using the "node:"
// scheme.
{

View File

@ -0,0 +1,60 @@
'use strict';
require('../common');
const {
injectAndCodeSign,
skipIfSingleExecutableIsNotSupported,
} = require('../common/sea');
skipIfSingleExecutableIsNotSupported();
// This tests the creation of a single executable application which uses the
// V8 code cache.
const fixtures = require('../common/fixtures');
const tmpdir = require('../common/tmpdir');
const { copyFileSync, writeFileSync, existsSync } = require('fs');
const { execFileSync } = require('child_process');
const { join } = require('path');
const { strictEqual } = require('assert');
const assert = require('assert');
const inputFile = fixtures.path('sea.js');
const requirableFile = join(tmpdir.path, 'requirable.js');
const configFile = join(tmpdir.path, 'sea-config.json');
const seaPrepBlob = join(tmpdir.path, 'sea-prep.blob');
const outputFile = join(tmpdir.path, process.platform === 'win32' ? 'sea.exe' : 'sea');
tmpdir.refresh();
writeFileSync(requirableFile, `
module.exports = {
hello: 'world',
};
`);
writeFileSync(configFile, `
{
"main": "sea.js",
"output": "sea-prep.blob",
"useCodeCache": true
}
`);
// Copy input to working directory
copyFileSync(inputFile, join(tmpdir.path, 'sea.js'));
execFileSync(process.execPath, ['--experimental-sea-config', 'sea-config.json'], {
cwd: tmpdir.path
});
assert(existsSync(seaPrepBlob));
copyFileSync(process.execPath, outputFile);
injectAndCodeSign(outputFile, seaPrepBlob);
const singleExecutableApplicationOutput = execFileSync(
outputFile,
[ '-a', '--b=c', 'd' ],
{ env: { COMMON_DIRECTORY: join(__dirname, '..', 'common') } });
strictEqual(singleExecutableApplicationOutput.toString(), 'Hello, world! 😊\n');