Embedding the Engine
For Pascal developers who want to embed the GocciaScript engine in their own applications.
Executive Summary
- Quick start —
TGocciaRuntime.Create(...)creates the runtime layer for file loading and runtime extension installation; apply a runtime profile or install concrete runtime extensions for runtime globals andgoccia:runtime modules.TGocciaEngine.Create(...)remains available for core-language-only embedders and requires an explicit executor (TGocciaInterpreterExecutorfor tree-walk orTGocciaBytecodeExecutorfor bytecode VM) - Sandboxing — Fix what the engine may reach with an immutable capability set (
Noneby default); choose runtime extensions explicitly; inject custom globals viaDefineLexicalBinding; enforce execution limits via timeout or instruction cap - Module resolution — Pluggable resolver with extensionless imports, import maps, custom content providers, virtual modules, and host modules
- Transparent GC — Mark-and-sweep GC initializes automatically; FPU exceptions are masked for IEEE 754 semantics
Native application embedding is an important secondary GocciaScript goal. TGocciaRuntime is the shared Pascal embedding entry point for the runtime layer: filesystem module content loading, runtime module dispatch, and extension installation. Runtime globals such as console, fetch, and URL, plus import-only modules such as goccia:json5, goccia:toml, goccia:yaml, goccia:csv, goccia:tsv, goccia:jsonl, goccia:semver, goccia:test, and node:async_hooks, usually come from ApplyLoaderRuntimeProfile. TGocciaEngine remains available through Runtime.Engine and as a core-language-only API for embedders that intentionally do not want runtime globals or runtime modules.
Quick Start
The simplest way to run a script with the loader runtime surface (uses Classes, Goccia.Runtime, Goccia.RuntimeProfiles.Loader;):
Source := TStringList.Create;Source.Text := 'console.log("hello from GocciaScript");';Runtime := TGocciaRuntime.Create('<inline>', Source);tryApplyLoaderRuntimeProfile(Runtime.Core);Runtime.Execute;finallyRuntime.Free;Source.Free;end;
For files, load app.js into the Source list, pass the real filename to TGocciaRuntime.Create('app.js', Source), and apply the runtime profile or runtime extensions your host needs before executing. Use TGocciaEngine.RunScript* only for core-language scripts that do not need runtime file loading, runtime globals, or goccia: runtime modules.
Engine API
Class Methods (One-Shot Execution)
These helpers create, execute, and clean up in a single call. The TGocciaEngine methods create a core-language-only engine. The TGocciaRuntime methods attach the runtime layer for file loading, but they do not apply a runtime profile; install runtime extensions explicitly when scripts need runtime globals such as console, fetch, and URL, or import-only modules such as goccia:json5, goccia:toml, goccia:yaml, goccia:csv, goccia:tsv, goccia:jsonl, goccia:semver, or node:async_hooks.
| Method | Description |
|---|---|
TGocciaEngine.RunScript(Source) | Execute source text with core language built-ins only |
TGocciaEngine.RunScript(Source, FileName) | Execute source text with a diagnostic filename |
TGocciaEngine.RunScriptFromStringList(Source, FileName) | Execute from a caller-provided TStringList |
TGocciaRuntime.RunScript(Source, FileName) | Execute source text through the runtime layer |
TGocciaRuntime.RunScriptFromFile(FileName) | Load and execute a file through the runtime layer |
TGocciaRuntime.RunScriptFromStringList(Source, FileName) | Execute from a TStringList through the runtime layer |
All methods return TGocciaScriptResult — a record containing the result value, per-phase timing (in microseconds), and the filename. |
SourceType is an engine-level language option, not a runtime option. Set Engine.SourceType (or use the CLI --source-type=script|module) to choose script source or module source for the entry file. File names ending in .mjs or .mts infer module source unless an explicit source type is provided. File loading is separate: TGocciaEngine one-shot helpers accept source text or caller-provided TStringList instances only, while TGocciaRuntime.RunScriptFromFile is the runtime convenience API for loading an entry file.
TGocciaEngine.Execute remains the public "run the whole source pipeline and execute" API for embedders. Hosts that need parse artifacts without executing can call TGocciaSourcePipeline.Parse directly; its result object owns the AST and source map until the caller frees the result or transfers ownership with TakeProgramNode / TakeSourceMap. Hosts should use the source-pipeline entry points for module source, dynamic Function validation, and expression fragments rather than constructing TGocciaParser directly; this keeps parser policy in one place.
Instance Usage (Long-Lived Engine)
For interactive sessions (REPL, editor integration) or when you need to execute multiple scripts in the same scope, create an engine instance directly:
usesClasses,Goccia.Engine,Goccia.Executor.Interpreter,Goccia.Values.Primitives;varEngine: TGocciaEngine;Executor: TGocciaInterpreterExecutor;Source: TStringList;ScriptResult: TGocciaScriptResult;beginSource := TStringList.Create;Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('session', Source, Executor);try// First execution — defines a variableSource.Text := 'const x = 42;';Engine.Execute;// Second execution — uses the variable from the firstSource.Text := 'x + 1;';ScriptResult := Engine.Execute;WriteLn(ScriptResult.Result.ToStringLiteral.Value); // 43finallyEngine.Free;end;finallyExecutor.Free;Source.Free;end;end;
The TStringList is passed by reference — update its contents and call Execute again to run new code in the same global scope. Variables, functions, and classes defined in previous executions remain available.
Engine Constructor and Executor Ownership
Callers must pass an explicit executor — TGocciaInterpreterExecutor (in Goccia.Executor.Interpreter) for tree-walk or TGocciaBytecodeExecutor (in Goccia.Executor.Bytecode) for the bytecode VM. The engine does not own the executor; the caller frees it after the engine. TGocciaRuntime's file/source convenience overloads handle this internally for embedders who want the default interpreter setup. Every constructor also has an overload taking a TGocciaCapabilities that fixes what the engine may reach outside the process; the overloads without one use TGocciaCapabilities.None. See Permissions. For net, AllowsNetHost(host, port) judges a URL before any lookup and AllowsNetAddress(host, port, address) judges the address the host resolved to. A private, loopback, or link-local address passes only when every layer names it — with the standalone private scope or an IP or CIDR scope covering it — so TGocciaCapabilities.None.Allow(gcNet, 'private') reaches local services and nothing public, while a host allow alone never reaches a name that resolves privately. Config trust is a CLI policy, not an engine one: nothing in the engine reads a goccia.json or a trust store. An embedder that honors project config files reads a file's permission request with ReadConfigPermissionRequest (in Goccia.CLI.Permissions, from the entries CLI.ConfigFile.ParseConfigFile returns) and decides for itself whether to grant it — for example by passing it to ResolveCapabilities with AConfigGrantsAccepted set to its own decision. NormalizedPermissionBlock and PermissionBlockHash give the same canonical block and SHA-256 the CLI trusts, if the host wants to keep its own record. Set Engine.CapabilityProvenance to say where the set came from in the capabilities.effective audit event. See Permissions — Config trust.
Automatic Semicolon Insertion
ASI is disabled by default. To enable ECMAScript-compliant automatic semicolon insertion (ES2026 §12.10), include cfASI in the engine compatibility set after creating the engine:
Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('app.js', Source, Executor);Engine.Compatibility := [cfASI]; // Semicolons are now optional per ES2026 rulesEngine.Execute;finallyEngine.Free;Executor.Free;end;
When enabled, the parser inserts virtual semicolons at newline boundaries, before }, and at EOF. Restricted productions (return, throw, break) follow the [no LineTerminator here] rules.
Timing
All RunScript* methods and the Execute method return a TGocciaScriptResult record that includes nanosecond-precision timing for each pipeline phase:
usesTimingUtils,Goccia.Engine;varScriptResult: TGocciaScriptResult;beginScriptResult := TGocciaEngine.RunScript(Source, 'bench.js');WriteLn('Lex: ', FormatDuration(ScriptResult.LexTimeNanoseconds));WriteLn('Parse: ', FormatDuration(ScriptResult.ParseTimeNanoseconds));WriteLn('Execute: ', FormatDuration(ScriptResult.ExecuteTimeNanoseconds));WriteLn('Total: ', FormatDuration(ScriptResult.TotalTimeNanoseconds));end;
FormatDuration (from TimingUtils) automatically selects the appropriate unit: ns for values below 0.5μs, μs for values below 0.5ms, ms with two decimal places for values up to 10s, and s above that.
TimingUtils provides three clock functions: GetNanoseconds and GetMilliseconds for monotonic duration timing (clock_gettime with CLOCK_MONOTONIC on Linux and CLOCK_MONOTONIC_RAW on macOS, QueryPerformanceCounter on Windows), and GetEpochNanoseconds for wall-clock epoch time (clock_gettime(CLOCK_REALTIME) on Unix/macOS, GetSystemTimeAsFileTime on Windows).
Host-Controlled Time And Randomness
Before attaching runtime extensions, use the engine-owned host environment; see Host Environment for deterministic setup and providers.
Engine Lifecycle & Realm Isolation
Every TGocciaEngine owns an initial TGocciaRealm (Goccia.Realm.pas) — the engine's ECMA-262 Realm Record for mutable intrinsic prototype objects (Array.prototype, Object.prototype, Map.prototype, every error prototype, every Temporal prototype, and so on), global state links, the realm [[TemplateMap]], loaded-module host state, and host-defined data. The realm is created in the engine constructor and torn down in the engine destructor; tear-down unpins every prototype and cached template object the realm owns so the GC can collect them before the next engine starts up.
This is the strongest isolation boundary the engine provides. Two engines created back-to-back on the same thread see pristine intrinsics — userland mutations on one engine's Array.prototype cannot leak into the next engine's Array.prototype, even if the mutation added a non-configurable property that JS-level cleanup cannot reverse.
When this matters for embedders
- Test runners and conformance harnesses — Per-file engine recreation is the cleanest way to give each test file fresh intrinsics.
GocciaTestRunnerdoes this automatically; if you build a custom runner, free the engine between files rather than reusing it. - Sandboxes evaluating untrusted scripts — Each script can mutate
Object.prototype. Recreating the engine between scripts is the only reliable way to guarantee the next script starts from a clean slate. - REPLs and long-lived sessions — These intentionally share intrinsics across
Executecalls (so users can mutateArray.prototypefrom one line and observe the change on the next). Reuse the same engine instance.
What you do not need to do
- Do not call
SetCurrentRealmdirectly. Engine, interpreter, and bytecode entry points make the realm current throughTGocciaExecutionContextStack;CurrentRealmremains only as the lookup facade used by value units. - Do not pin or unpin prototype objects manually after engine startup. The realm pins everything stored in a slot via
SetSlot, and unpins them all at tear-down. - Do not cache prototype object pointers in long-lived Pascal state. Those objects are realm-scoped — they become invalid the moment the engine that owns them is freed. If you need the current
Array.prototype, look it up live (e.g. viaEngine.Interpreter.GlobalScope).
Threading
CurrentRealm is backed by thread-local execution-context state. Each worker thread that executes an engine gets its own realm stack. The thread pool used by GocciaTestRunner --jobs=N relies on this: each worker creates, enters, and destroys engines on its own thread, and realm tear-down on one worker has no effect on intrinsics seen by the others.
Module Resolution
The engine uses a pluggable module resolver (TGocciaModuleResolver) that supports extensionless imports, import-map-style aliases, and index file resolution.
Extension-Free Imports
Import paths can omit file extensions. The engine's own list comes first, in order: .js, .jsx, .ts, .tsx, .mjs, .mts, .json, .txt, .md. Installed runtime extensions append the structured-data extensions they own, so a host that applies the loader runtime profile also resolves .json5, .jsonc, .jsonl, .toml, .yaml, .yml, .csv, and .tsv:
// These all resolve through the shared module extension list:import { add } from "./math-utils.js"; // explicit extensionimport { add } from "./math-utils"; // extension resolved automaticallyimport { content } from "./notes"; // resolves to ./notes.txt or ./notes.md
Directory imports resolve to index files:
import { setup } from "./utils"; // resolves to ./utils/index.js (or .ts, .jsx, etc.)
Path Aliases
Aliases follow WHATWG import map matching rules:
Engine.AddAlias('lodash', 'vendor/lodash/index.js'); // exact match onlyEngine.AddAlias('@/', 'src/'); // prefix matchEngine.AddAlias('@/components/', 'ui/lib/'); // more specific prefix
For an embedded engine, the alias target is resolved relative to the resolver's base directory, which defaults to the entry file's directory. In the shared CLI hosts, relative --alias targets use the invocation directory, while aliases loaded from configuration use the active project configuration file's directory, including aliases inherited through extends.
Exact vs prefix matching: A key without a trailing / is an exact match only. A key with a trailing / is a prefix match and appends the unmatched suffix to the target. This means lodash matches import "lodash" but not import "lodash/fp", while @/ matches @/utils/math.
Longest-prefix matching: When multiple prefix aliases overlap (e.g., @/ and @/components/), the resolver always picks the longest matching key. This means @/components/Button uses the @/components/ alias, not @/.
Scripts can then import using the alias:
import { formatDate } from "@/utils/dates";
TGocciaModuleResolver also exposes LoadImportMap(path) and DiscoverProjectConfig(startDirectory) helpers for browser-style import map JSON and goccia.json project configuration files. The shared CLI hosts (GocciaRunner, GocciaTestRunner, and GocciaBenchmarkRunner) all use Goccia.Modules.Configuration.ConfigureModuleResolver(...) on top of this resolver surface.
Config file discovery is automatic for CLI apps — TGocciaCLIApplication discovers goccia.toml / goccia.json5 / goccia.json (priority order: TOML > JSON5 > JSON) from the entry file's directory upward and applies config values before execution. When embedding the engine directly, this does not happen automatically. To replicate it, use the general-purpose CLI.ConfigFile unit (DiscoverConfigFile, ApplyConfigFile). Note that ApplyConfigFile only handles .json by default — to support .json5 and .toml, call EnsureConfigParsersRegistered (in Goccia.CLI.Application) first, or register your own parsers with RegisterConfigParser. For import-map resolution only, use TGocciaModuleResolver.DiscoverProjectConfig and LoadImportMap. See Configuration File for the full reference.
Bare Specifiers and node_modules
A bare specifier is refused for an embedded engine too; grant node_modules through the engine's capability set, optionally capping the ancestor walk. See Module Resolution for the supported exports subset, the module-field deviation from Node, and the CommonJS refusal.
Caps := TGocciaCapabilities.None.Allow(gcImport, 'node_modules'); // walk up from each importerCaps := TGocciaCapabilities.None.Allow(gcImport, 'node_modules=/srv/app'); // ...but never above /srv/app
Custom Resolver
For resolution logic outside that surface (URL imports, in-memory modules, or a package layout of your own), subclass TGocciaModuleResolver and override the Resolve method:
usesGoccia.Engine,Goccia.Executor.Interpreter,Goccia.Modules.Resolver;typeTMyResolver = class(TGocciaModuleResolver)publicfunction Resolve(const AModulePath, AImportingFilePath: string): string; override;end;function TMyResolver.Resolve(const AModulePath, AImportingFilePath: string): string;begin// Custom logic here — fall back to inherited for standard resolutionResult := inherited Resolve(AModulePath, AImportingFilePath);end;varResolver: TMyResolver;Engine: TGocciaEngine;Executor: TGocciaInterpreterExecutor;beginResolver := TMyResolver.Create('/path/to/project');Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('app.js', Source, Resolver, Executor);tryEngine.Execute;finallyEngine.Free;end;finallyExecutor.Free;Resolver.Free; // caller owns the resolver when injectedend;end;
When no custom resolver is provided, the engine creates a default TGocciaModuleResolver whose base directory is the entry file's directory.
Custom Content Provider
Resolution and content loading are separate concerns. A custom resolver decides which module path to load; a custom content provider decides how the module text or JSON is retrieved once that path is resolved.
Use TGocciaModuleContentProvider when your modules come from memory, an archive, a database, or some other non-filesystem source:
usesClasses,SysUtils,Goccia.Engine,Goccia.Executor.Interpreter,Goccia.Modules.ContentProvider,Goccia.Modules.Loader,Goccia.Modules.Resolver;typeTMemoryResolver = class(TGocciaModuleResolver)publicfunction Resolve(const AModulePath, AImportingFilePath: string): string; override;end;TMemoryContentProvider = class(TGocciaModuleContentProvider)publicfunction Exists(const APath: string): Boolean; override;function LoadContent(const APath: string): TGocciaModuleContent; override;function TryGetLastModified(const APath: string;out ALastModified: TDateTime): Boolean; override;end;function TMemoryResolver.Resolve(const AModulePath,AImportingFilePath: string): string;beginResult := AModulePath;end;function TMemoryContentProvider.Exists(const APath: string): Boolean;beginResult := APath = 'memory:/dep.js';end;function TMemoryContentProvider.LoadContent(const APath: string): TGocciaModuleContent;beginif APath = 'memory:/dep.js' thenExit(TGocciaModuleContent.Create('export const value = 42;', 0));raise Exception.Create('Module content not found: ' + APath);end;function TMemoryContentProvider.TryGetLastModified(const APath: string;out ALastModified: TDateTime): Boolean;beginALastModified := 0;Result := False;end;varEngine: TGocciaEngine;Executor: TGocciaInterpreterExecutor;ModuleLoader: TGocciaModuleLoader;Resolver: TMemoryResolver;Provider: TMemoryContentProvider;beginResolver := TMemoryResolver.Create;Provider := TMemoryContentProvider.Create;Executor := TGocciaInterpreterExecutor.Create;tryModuleLoader := TGocciaModuleLoader.Create('memory:/app.js', Resolver,Provider);tryEngine := TGocciaEngine.Create('memory:/app.js', Source,ModuleLoader, Executor);tryEngine.Execute;finallyEngine.Free;end;finallyModuleLoader.Free; // caller owns injected module loadersend;finallyExecutor.Free;Provider.Free; // caller owns injected providersResolver.Free;end;end;
TGocciaEngine also accepts an injected module loader via its constructor. When no loader is supplied, it creates a default TGocciaModuleLoader with the standard resolver but no filesystem content provider. TGocciaRuntime installs the filesystem provider when attached, and the loader checks every read through it against the set: static imports inside the project are exempt, everything else needs a read grant (Permissions). Untrusted-source hosts can deny read and supply virtual modules, host modules, or a bounded custom content provider explicitly. Core-language-only embedders that need imports should inject their own provider. With no provider installed, a module load that gets as far as retrieval is refused with a script-catchable Error carrying code === "ERR_MODULE_LOADING_UNSUPPORTED" rather than a Pascal exception. That covers retrieval only: resolution runs first, so a specifier the resolver rejects — with the default resolver, one whose file is absent from the host filesystem — fails before the provider is consulted, carries no code, and raises TGocciaRuntimeError across the engine boundary for a static import. Keep guarding the boundary; see Module loading errors.
Virtual Modules
Prefer virtual modules for host configuration consumed through ordinary ES imports. See Virtual Module Configuration for their schema and behavior.
Host Modules
Host modules expose values created programmatically by an embedder. They are checked before filesystem resolution:
usesGoccia.Modules;varModule: TGocciaModule;beginModule := TGocciaModule.Create('my-lib');Module.ExportsTable.Add('version', TGocciaStringLiteralValue.Create('1.0.0'));Engine.RegisterHostModule('my-lib', Module);end;
Scripts can then import from the host module by name:
import { version } from "my-lib";console.log(version); // "1.0.0"
RegisterHostModuleProvider is lazy; former global module names remain supported without warnings.
Console Output Capture
By default, console.log and friends write directly to stdout. The OutputCallback property lets you intercept all console output programmatically:
usesClasses,Goccia.Builtins.Console,Goccia.Runtime,Goccia.RuntimeExtensions.Console,Goccia.RuntimeProfiles.Loader;typeTMyLogger = classprocedure OnConsoleOutput(const AMethod, ALine: string);end;procedure TMyLogger.OnConsoleOutput(const AMethod, ALine: string);begin// AMethod is 'log', 'warn', 'error', 'info', 'debug', 'dir',// 'assert', 'count', 'timeEnd', 'timeLog', 'trace', 'table', 'group'// ALine is the fully formatted output stringWriteLn('[', AMethod, '] ', ALine);end;varConsoleExtension: TGocciaConsoleRuntimeExtension;Runtime: TGocciaRuntime;Source: TStringList;Logger: TMyLogger;beginLogger := TMyLogger.Create;Source := TStringList.Create;Source.Text := 'console.log("hello"); console.warn("careful");';Runtime := TGocciaRuntime.Create('app.js', Source);tryApplyLoaderRuntimeProfile(Runtime.Core);ConsoleExtension := TGocciaConsoleRuntimeExtension(Runtime.FindRuntimeExtension(TGocciaConsoleRuntimeExtension));ConsoleExtension.BuiltinConsole.OutputCallback := Logger.OnConsoleOutput;Runtime.Execute;// Output:// [log] hello// [warn] Warning: carefulfinallyRuntime.Free;Source.Free;Logger.Free;end;end;
The callback type is:
TGocciaConsoleOutputCallback = procedure(const AMethod, ALine: string) of object;
When OutputCallback is assigned, it takes priority over both OutputLines (the TStrings capture property) and the default WriteLn path. When not assigned, existing behavior is unchanged.
LogCallback (Independent Logging Channel)
LogCallback fires on every console call regardless of the Enabled flag, independent of the primary output path. This makes it safe for worker threads where Enabled is False to suppress stdout:
ConsoleExtension.BuiltinConsole.LogCallback := MyHandler.OnLog;ConsoleExtension.BuiltinConsole.Enabled := False; // no stdout, but LogCallback still fires
The CLI hosts based on TGocciaCLIApplication (GocciaRunner, GocciaTestRunner, GocciaBenchmarkRunner, and GocciaREPL) apply a runtime profile and use LogCallback internally for --log=<file>, which captures console output to a log file in [method] line format. The TestRunner silences workers via Enabled := False (not by replacing JS methods), so LogCallback fires on every console call even in parallel mode. File writes are serialized with a critical section so --log is thread-safe even with --jobs=N.
Built-in Registration
Core language built-ins (Math, Object, Array, JSON, Promise, Temporal, typed arrays, etc.) are registered by TGocciaEngine. Runtime globals that are not part of the language core (Console, TextEncoder/TextDecoder, URL, fetch, performance, etc.) and import-only runtime modules (goccia:csv, goccia:json5, goccia:jsonl, goccia:toml, goccia:tsv, goccia:yaml, goccia:semver, goccia:test, node:async_hooks) are provided by concrete runtime units. Hosts can call ApplyLoaderRuntimeProfile for the ordinary CLI runtime surface or install only the extension classes they need for a smaller runtime surface. ApplyLoaderRuntimeProfile(ARuntime, False) suppresses the goccia:test registration for a host that installs TGocciaTestingLibraryRuntimeExtension itself — the testing globals are opt-in through that extension and are never part of the profile. The goccia: modules expose named exports only; callers can use namespace imports such as import * as CSV from "goccia:csv" when they want namespace-style access.
When you already have an engine, pass it to the runtime constructor:
Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('app.js', Source, Executor);Runtime := TGocciaRuntime.Create(Engine);tryApplyLoaderRuntimeProfile(Runtime.Core);Runtime.Execute;finallyRuntime.Free;Engine.Free;end;finallyExecutor.Free;end;
Passing an engine does not transfer ownership by default. Use TGocciaRuntime.Create(Engine, True) when the runtime should free the engine. The executor is always owned by the caller — free it after the engine.
Runtime Extensions
Runtime extensions are ordinary Pascal classes installed on TGocciaRuntimeCore:
| Extension/profile | Provides | Notes |
|---|---|---|
ApplyLoaderRuntimeProfile | ordinary CLI runtime surface: console, goccia: data-format/SemVer modules, text assets, performance, text encoding, URL/fetch, and related runtime globals | Used by GocciaRunner (both modes) and GocciaREPL |
TGocciaTestingLibraryRuntimeExtension | describe, test, expect | Testing framework; TestRunner installs this through ApplyTestRunnerRuntimeProfile |
TGocciaBenchmarkRuntimeExtension | suite, bench | Benchmark framework; BenchmarkRunner installs this through ApplyBenchmarkRunnerRuntimeProfile |
TGocciaFFIRuntimeExtension | FFI.open, FFILibrary, FFIPointer | Native shared-library FFI; needs the ffi capability, which CLI tools grant for --allow-ffi or "allow-ffi" in a config file's permissions block |
When embedding, grant ffi in the engine's capability set and call InstallFFIIfGranted(Runtime); the extension refuses to attach without the grant, and FFI.open checks each library path against the ffi scopes.
To add the test framework for a custom test runner:
Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('tests/my-test.js', Source, Executor);Runtime := TGocciaRuntime.Create(Engine, True);tryApplyTestRunnerRuntimeProfile(Runtime.Core);Runtime.Execute;finallyRuntime.Free;end;finallyExecutor.Free;end;
Runtime globals and runtime modules can be reduced by installing only concrete extensions, for example Runtime.Install(TGocciaConsoleRuntimeExtension.Create).
Preprocessors and Compatibility
| System | Type | Default | Purpose |
|---|---|---|---|
Preprocessors | TGocciaPreprocessors | [ppJSX] | Source transformations before parsing |
Compatibility | TGocciaCompatibilityFlags | [] | ECMAScript conformance and legacy-behavior toggles; leave empty for the recommended defaults |
WarningUnsupportedFeatures | Boolean | False | Parser diagnostic policy for disabled syntax; True restores warning/no-op recovery without enabling compatibility semantics |
SourceType | TGocciaSourceType | stScript | Load entry as script source (default) or module source; .mjs and .mts file names infer stModule |
StrictTypes | Boolean | False | Runtime enforcement of type annotations (works in both interpreter and bytecode); setter propagates to the active executor and interpreter scope |
Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('app.js', Source, Executor);Engine.Preprocessors := []; // Disable JSXEngine.Compatibility := [cfASI,cfNonStrictMode,cfArgumentsObject,cfLabel,cfTraditionalFor,cfForIn,cfWhileLoops,cfExperimentalJSModuleSource]; // Enable selected source-pipeline flagsEngine.WarningUnsupportedFeatures := True; // Optional migration mode for disabled syntax diagnosticsEngine.SourceType := stModule; // Run entry as module source (top-level this is undefined; import.meta resolves)Engine.StrictTypes := True; // Enforce type annotations in both execution modesfinallyExecutor.Free;end;
When SourceType is stModule, Execute runs the entry program in a fresh module scope (skModule) with this = undefined, mirroring the semantics imported modules already receive from the module loader (ES2026 §16.2.1.6.4). The CLI surface for this is --source-type=script|module and the matching goccia.json key "source-type". Without an explicit source type, .mjs and .mts entry files are loaded as module source.
Top-level binding persistence differs between source types. With SourceType = stScript, the engine reuses Interpreter.GlobalScope across every call to Execute, which is what makes the long-lived engine pattern above work: const x = 42 defined in one Execute is visible to the next. With SourceType = stModule, each Execute allocates a brand-new skModule child scope, so top-level let/const/class declarations live and die with that single call. If callers need cross-Execute persistence, keep SourceType at stScript (the default) or expose the desired symbols through the global scope (e.g. Engine.RegisterGlobal(...) or Engine.Interpreter.GlobalScope.DefineLexicalBinding(...)).
Injecting Custom Globals
You can inject Pascal functions and values into the script's global scope by working with the engine's interpreter scope directly.
Injecting a Value
usesClasses,SysUtils,Goccia.Engine,Goccia.Executor.Interpreter,Goccia.Scope,Goccia.Values.Primitives;varEngine: TGocciaEngine;Executor: TGocciaInterpreterExecutor;Source: TStringList;beginSource := TStringList.Create;Source.Text := 'APP_VERSION;';Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('app.js', Source, Executor);try// Inject a constant into the global scopeEngine.Interpreter.GlobalScope.DefineLexicalBinding('APP_VERSION',TGocciaStringLiteralValue.Create('1.2.3'),dtConst);Engine.Execute; // evaluates "1.2.3"finallyEngine.Free;end;finallyExecutor.Free;Source.Free;end;end;
Injecting a Native Function
Native functions are Pascal methods exposed to GocciaScript. They receive arguments and a this value, and return a TGocciaValue.
usesClasses,DateUtils,SysUtils,Goccia.Arguments.Collection,Goccia.Engine,Goccia.Executor.Interpreter,Goccia.Scope,Goccia.Values.NativeFunction,Goccia.Values.Primitives;typeTMyHost = classfunction GetTimestamp(const AArgs: TGocciaArgumentsCollection;const AThisValue: TGocciaValue): TGocciaValue;end;function TMyHost.GetTimestamp(const AArgs: TGocciaArgumentsCollection;const AThisValue: TGocciaValue): TGocciaValue;beginResult := TGocciaNumberLiteralValue.Create(DateTimeToUnix(Now));end;varEngine: TGocciaEngine;Executor: TGocciaInterpreterExecutor;Source: TStringList;Host: TMyHost;Func: TGocciaNativeFunctionValue;beginHost := TMyHost.Create;Source := TStringList.Create;Source.Text := 'getTimestamp();';Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('app.js', Source, Executor);try// Create a native function: callback, name, arity (-1 for variadic)Func := TGocciaNativeFunctionValue.Create(Host.GetTimestamp, 'getTimestamp', 0);Engine.Interpreter.GlobalScope.DefineLexicalBinding('getTimestamp', Func, dtConst);Engine.Execute;finallyEngine.Free;end;finallyExecutor.Free;Source.Free;Host.Free;end;end;
The callback signature is:
TGocciaNativeFunctionCallback = function(const AArgs: TGocciaArgumentsCollection;const AThisValue: TGocciaValue): TGocciaValue of object;
AArgs— The arguments collection. UseAArgs.LengthandAArgs.GetElement(I)to access arguments.AThisValue— Thethisbinding (relevant for method calls).- Return value — Must be a
TGocciaValue. UseTGocciaUndefinedLiteralValue.UndefinedValuefor void functions.
Injecting a Native Object with Methods
For a more structured API, create a TGocciaObjectValue and register native methods on it:
usesClasses,SysUtils,Goccia.Arguments.Collection,Goccia.Engine,Goccia.Executor.Interpreter,Goccia.Scope,Goccia.Values.NativeFunction,Goccia.Values.ObjectValue,Goccia.Values.Primitives;typeTFileSystemAPI = classfunction ReadFile(const AArgs: TGocciaArgumentsCollection;const AThisValue: TGocciaValue): TGocciaValue;function Exists(const AArgs: TGocciaArgumentsCollection;const AThisValue: TGocciaValue): TGocciaValue;end;function TFileSystemAPI.ReadFile(const AArgs: TGocciaArgumentsCollection;const AThisValue: TGocciaValue): TGocciaValue;varPath: string;Content: TStringList;beginPath := AArgs.GetElement(0).ToStringLiteral.Value;Content := TStringList.Create;tryContent.LoadFromFile(Path);Result := TGocciaStringLiteralValue.Create(Content.Text);finallyContent.Free;end;end;function TFileSystemAPI.Exists(const AArgs: TGocciaArgumentsCollection;const AThisValue: TGocciaValue): TGocciaValue;beginResult := TGocciaBooleanLiteralValue.Create(FileExists(AArgs.GetElement(0).ToStringLiteral.Value));end;varEngine: TGocciaEngine;Executor: TGocciaInterpreterExecutor;Source: TStringList;API: TFileSystemAPI;FSObject: TGocciaObjectValue;beginAPI := TFileSystemAPI.Create;Source := TStringList.Create;Source.Text := 'fs.readFile("data.txt");';Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('app.js', Source, Executor);tryFSObject := TGocciaObjectValue.Create;FSObject.RegisterNativeMethod(TGocciaNativeFunctionValue.Create(API.ReadFile, 'readFile', 1));FSObject.RegisterNativeMethod(TGocciaNativeFunctionValue.Create(API.Exists, 'exists', 1));Engine.Interpreter.GlobalScope.DefineLexicalBinding('fs', FSObject, dtConst);Engine.Execute;finallyEngine.Free;end;finallyExecutor.Free;Source.Free;API.Free;end;end;
Scripts can then call fs.readFile("path") and fs.exists("path") as if they were built-in.
Reading Return Values
TGocciaEngine.RunScript, TGocciaRuntime.RunScript, and Execute return a TGocciaScriptResult record. Its Result field holds the last evaluated expression as a TGocciaValue; use the type conversion methods on that value to extract Pascal values:
varScriptResult: TGocciaScriptResult;beginScriptResult := TGocciaEngine.RunScript('40 + 2;');// Type checkingif ScriptResult.Result is TGocciaNumberLiteralValue thenWriteLn(TGocciaNumberLiteralValue(ScriptResult.Result).Value); // 42.0// Generic conversion methods (available on all TGocciaValue)WriteLn(ScriptResult.Result.ToStringLiteral.Value); // "42"WriteLn(ScriptResult.Result.ToNumberLiteral.Value); // 42.0WriteLn(ScriptResult.Result.ToBooleanLiteral.Value); // TrueWriteLn(ScriptResult.Result.TypeOf); // "number"end;
Value Type Hierarchy
| Pascal Class | JavaScript Type | Key Properties |
|---|---|---|
TGocciaNumberLiteralValue | number | .Value: Double, .IsNaN, .IsInfinity |
TGocciaStringLiteralValue | string | .Value: string |
TGocciaBooleanLiteralValue | boolean | .Value: Boolean |
TGocciaNullLiteralValue | null | Singleton via .NullValue |
TGocciaUndefinedLiteralValue | undefined | Singleton via .UndefinedValue |
TGocciaObjectValue | object | .GetProperty(Name), .AssignProperty(Name, Value) |
TGocciaArrayValue | object (array) | .Elements: TGocciaValueList |
TGocciaFunctionValue | function | .Call(Args, ThisValue) |
TGocciaInstanceValue | object (class instance) | .ClassValue, .GetProperty(Name) |
Error Handling
GocciaScript errors surface as Pascal exceptions. See Errors for the JavaScript-side error types and display format, and JSON Output for the JSON output envelope. On the Pascal side, wrap execution in try...except:
usesSysUtils,Goccia.Engine,Goccia.Error;tryTGocciaEngine.RunScript('undeclaredVariable;');excepton E: TGocciaRuntimeError doWriteLn('Runtime error: ', E.Message);on E: TGocciaLexerError doWriteLn('Lexer error: ', E.Message);on E: TGocciaSyntaxError doWriteLn('Syntax error: ', E.Message);on E: Exception doWriteLn('Unexpected error: ', E.Message);end;
TGocciaLexerError inherits from TGocciaSyntaxError, so catching TGocciaSyntaxError alone covers both lexer and parser errors. Catch TGocciaLexerError first only when you need to distinguish them for diagnostics.
| Exception Class | When |
|---|---|
TGocciaSyntaxError | All early errors (base class for lexer and parser failures) |
TGocciaLexerError | Invalid tokens (unterminated strings, invalid characters) — subclass of TGocciaSyntaxError |
TGocciaRuntimeError | Execution errors (type errors, reference errors, throw statements) |
TGocciaTypeError | Type-specific runtime error |
TGocciaReferenceError | Undefined variable access |
TGocciaThrowValue | JavaScript throw — wraps any thrown value including RangeError. Also raised from Execute, ExecuteProgram, RunModule, and RunModuleInScope with the reason of a promise the run left rejected with no handler (Errors). Engine.UnhandledRejections := urIgnore turns that off: the promises then stay on the microtask queue, where TGocciaMicrotaskQueue.Instance.TakeUnhandledRejection hands the oldest to a host and forgets the others. A host can ask while the run is in progress (from a native callback) or after an ExecuteProgram / RunModule that returned normally; Execute clears its queue before it returns, and freeing an engine while none is running clears the thread's, so nothing is left to take after either. To see every rejection, also of a run made with Execute, set Engine.OnUnhandledRejection: it is called for each such promise, oldest first, before the setting is applied (ADR 0125) |
EObjectCheck, EAccessViolation, EInvalidPointer, EDivByZero, EPrivilege, EExternalException | Engine-integrity faults. The engine re-raises these past every guest catch, so they escape Engine.Execute even while guest code is running: they mean a pointer, a mapping, or the heap is no longer trustworthy. Report and exit — do not resume the process, and do not treat one as a script failure. EOutOfMemory is deliberately not one of them and stays catchable. See ADR 0109 |
Execution Limits
Two mechanisms prevent runaway scripts: wall-clock timeouts and instruction limits. Both use thread-local storage and are safe with parallel workers — each thread gets its own independent counter. Whether either is armed on a thread is mirrored in GThreadPolls (Goccia.ThreadPolls), which is all that a value allocation and a bytecode call read when neither is.
Timeout
StartExecutionTimeout arms a wall-clock deadline. ClearExecutionTimeout disarms it. The check is sampled (every 1024th call site) to minimize overhead.
usesGoccia.Timeout;StartExecutionTimeout(5000); // 5 000 mstryEngine.Execute;finallyClearExecutionTimeout;end;
Raises TGocciaTimeoutError when the deadline is exceeded. A value of zero disables the timeout.
Instruction Limit
StartInstructionLimit caps the number of execution steps. In bytecode mode the counter increments on every dispatched instruction (exact). In interpreter mode it increments at function-call and loop-iteration checkpoints (approximate).
usesGoccia.InstructionLimit;StartInstructionLimit(1000000); // 1 000 000 stepstryEngine.Execute;finallyClearInstructionLimit;end;
Raises TGocciaInstructionLimitError when the limit is reached. A value of zero (the default) leaves the instruction budget inactive, so bytecode production dispatch omits the per-instruction poll. A positive limit selects the instrumented loop, which increments and checks the counter on every dispatched instruction.
Call Stack Depth Limit
SetMaxStackDepth caps the number of nested function calls. Exceeding the limit throws a JavaScript RangeError with the message "Maximum call stack size exceeded" (matching V8 convention). The TGocciaCLIApplication hosts set 2 200 frames (DEFAULT_MAX_STACK_DEPTH) and GocciaScriptLoaderBare sets 10 000, unless --max-stack overrides it; an embedder that never calls SetMaxStackDepth has no limit. A value of zero disables the limit entirely. In bytecode mode the memory ceiling then bounds the recursion instead: the VM's stacks are charged to TGarbageCollector.MaxBytes, and a recursion that would grow them past it throws the same catchable RangeError (ADR 0130). A module's top level is not a call. The entry's top level, and that of every module evaluated for a static import, an import() or a deferred namespace, allows the whole limit less the calls live when that module starts to evaluate, so a module imported from a top level allows all of it.
In bytecode mode the VM uses a trampoline: bytecode-to-bytecode calls are dispatched iteratively via an explicit frame stack, so the Pascal call stack stays flat regardless of JS call depth. The interpreter mode uses Pascal recursion and relies on the depth check to prevent overflow.
Every call of a function written in script counts as one nested call, however it is made: a plain or method call, new and super(), a getter or setter, a Proxy trap, a toString, valueOf or Symbol.toPrimitive method, an iterator's next, or a callback a built-in calls. A built-in function itself adds no frame, so [n].map(f) and the f it calls count as one. The interpreter does not count super() calls, accessors, Proxy traps, ToPrimitive methods or iterator methods. Most of those calls are native re-entries into the bytecode VM: the bytecode loop runs on a fresh Pascal stack frame instead of the trampoline, and each one costs a few kilobytes of native stack. How many fit depends on the thread's stack size and on the build. Once less than NATIVE_STACK_RESERVE (256 KiB, or a quarter of a stack smaller than 1 MiB) of the thread's stack is left, the VM throws the same RangeError instead of entering again, so no --max-stack value, including 0, lets these re-entries overflow the native stack. The check runs only where the bytecode loop is entered: a chain of native calls, such as Proxies forwarding to their targets or bound functions calling theirs, is not checked against the stack in between, and the delegation caps below bound only some of those chains. The bounds come from the operating system (/proc/self/maps and RLIMIT_STACK on Linux, pthread_get_stackaddr_np on Darwin, GetCurrentThreadStackLimits on Windows) and from the RTL's StackBottom where that is higher. On Linux the main thread's stack is taken to grow down to RLIMIT_STACK even where its mapping is not marked [stack], as under Valgrind. The first NATIVE_REENTRY_UNCHECKED_DEPTH (8) nested re-entries are not checked, so a program that does not nest them never looks the bounds up. Where they cannot be found, at most MAX_NATIVE_REENTRY_DEPTH (512) re-entries are live at once. A production x86-64 build on an 8 MiB stack, the Linux and macOS main-thread default and the test runner's worker size, fits 2 200 nested constructor, async-function, generator-next() or getter calls. A setter, a Proxy trap, a toString method or a callback from sort, JSON.stringify or Reflect.construct costs more, and recursion through one stops at between about 1 600 and 2 100 calls. Property lookups walk a chain of ordinary objects in a loop, with no length limit. A Proxy in the chain, a Proxy's target, a Proxy's trap and a handler that is itself a Proxy are entered by native calls, for every internal method a Proxy forwards ([[GetPrototypeOf]], [[SetPrototypeOf]], [[IsExtensible]], [[PreventExtensions]], [[GetOwnProperty]], [[DefineOwnProperty]], [[HasProperty]], [[Get]], [[Set]], [[Delete]], [[OwnPropertyKeys]], [[Call]] and [[Construct]]); at most MAX_PROPERTY_DELEGATION_DEPTH (2 000) of them are live at once, and past that the operation throws the same RangeError. A bound function between two Proxies is not counted, so a chain that alternates Proxies with bound functions, or a trap wrapped in several bound functions, can still exhaust the native stack. typeof and the callable check step through directly nested Proxies in a loop, since they cannot throw. Arrays, functions, classes and class instances in the chain are entered by native calls too, counted against MAX_OBJECT_DELEGATION_DEPTH (3 000). A prototype cycle closed through a Proxy, which ES2026 §10.1.2.1 OrdinarySetPrototypeOf accepts, ends this way. instanceof and Object.prototype.isPrototypeOf follow [[GetPrototypeOf]] in a loop instead and throw after MAX_PROXY_PROTOTYPE_STEPS (100 000) steps through a Proxy.
usesGoccia.StackLimit;SetMaxStackDepth(5000); // custom limit// SetMaxStackDepth(0); // no limitEngine.Execute;
The CLI tools expose all three limits as options; see Build System — Run Commands for usage.
FPU Exception Mask
FPU exceptions — divide-by-zero, overflow, underflow, invalid operation, denormalized operand, and precision loss — are hardware signals raised by the floating-point unit when an operation produces a special result. By default, FreePascal leaves some of these unmasked, which causes runtime exceptions on operations like 0.0 / 0.0 instead of returning NaN.
Both TGocciaEngine and TGocciaVM mask all FPU exceptions on creation (via SetExceptionMask) to enable IEEE 754 semantics (NaN, Infinity, -0). The previous mask is saved in the constructor and restored in the destructor, so the host application's FPU state is not permanently altered. This is transparent for one-shot execution (RunScript), but embedders creating long-lived engine instances should be aware that FPU exceptions are suppressed while the engine exists. If the host application depends on FPU exception handlers for its own error handling, those handlers will not fire while a TGocciaEngine or TGocciaVM instance is alive.
Microtask Queue (Promises)
The engine initializes a singleton microtask queue (TGocciaMicrotaskQueue) alongside the GC. Promise .then() callbacks are enqueued as microtasks and drained automatically after each Execute or ExecuteProgram call. Fetch completions are also pumped before these calls return, then their Promise reactions drain through the same microtask queue. Embedders do not need to drain either queue manually.
This means:
- All synchronous code in the script runs to completion first.
- All pending
.then()callbacks fire after the script finishes. - Chained
.then()handlers are processed in the same drain cycle. - On successful execution, the engine's pending
fetch()requests complete beforeExecutereturns; if execution throws, they are detached and their late completions are discarded. The engine neither waits for nor discards requests another engine on the same thread started, although its drain may settle those that have already completed (their reactions still run in the engine that owns them). The microtask queue is still only used for Promise reactions, not for network I/O.
The execution ordering follows ECMAScript specification semantics — the script is one macrotask, and microtasks drain after it completes. Thenable adoption is deferred via a microtask per the spec's PromiseResolveThenableJob.
// Promises work automatically — no manual queue management neededSource.Text := 'Promise.resolve(42).then((v) => { globalThis.answer = v; });';Engine.Execute; // microtasks drain before Execute returns
For long-lived engines (REPL-style), each Execute call drains its own microtasks. Promise callbacks from one execution will not leak into the next — even if the script throws an exception, the engine clears any pending microtasks in a finally block. Engines can also nest on a thread: an Execute that starts while a different engine's run is in progress (a sandbox runScript child, or another engine's Execute called from a native callback) drains and discards only its own jobs and leaves the enclosing engine's jobs queued, while a nested ExecuteProgram or RunModule drains the queue it was called in. Interpreter — Synchronous Microtask Queue describes the scopes behind this.
Garbage Collector
The engine initializes a mark-and-sweep garbage collector (TGarbageCollector) automatically. In most embedding scenarios, no manual GC interaction is needed. The GC collects unreachable values during execution.
For long-running engines (REPL-style), the GC runs automatically. If you need to trigger collection manually between script executions:
usesGoccia.GarbageCollector;TGarbageCollector.Instance.Collect;
Memory ceiling: The GC auto-detects physical memory and defaults to half of RAM, capped at 8 GB on 64-bit or 700 MB on 32-bit (512 MB fallback when detection fails). Override with MaxBytes to impose a custom limit. Allocations exceeding it raise a JavaScript RangeError:
TGarbageCollector.Instance.MaxBytes := 10 * 1024 * 1024; // 10 MB limit
Temporary roots: If your Pascal code holds references to TGocciaValue objects outside of any GocciaScript scope (e.g., in a Pascal list while the engine runs), protect them from collection:
TGarbageCollector.Instance.AddTempRoot(MyValue);tryEngine.Execute; // MyValue won't be collected during executionfinallyTGarbageCollector.Instance.RemoveTempRoot(MyValue);end;
Existing Embeddings
The repository includes these embedding examples:
| Program | File | Description |
|---|---|---|
GocciaRunner | source/app/GocciaRunner.dpr | Executes source files (.js, .jsx, .ts, .tsx, .mjs, .mts) from disk or stdin, with optional JSON output, injected globals, and execution timeouts for one-shot automation |
GocciaScriptLoaderBare | source/app/GocciaScriptLoaderBare.dpr | Core engine with a CLI-local print; no runtime profile |
GocciaREPL | source/app/GocciaREPL.dpr | Interactive read-eval-print loop (long-lived engine) |
GocciaTestRunner | source/app/GocciaTestRunner.dpr | Runs test suites with the test-runner runtime profile |
GocciaBenchmarkRunner | source/app/GocciaBenchmarkRunner.dpr | Runs benchmarks with the benchmark-runner runtime profile from files or stdin |
GocciaBundler | source/app/GocciaBundler.dpr | Bundler CLI host — compiles source files to .gbc without execution |
These serve as reference implementations for the patterns described above.
Application Base Class
TGocciaApplication (Goccia.Application.pas) provides the standard lifecycle for any GocciaScript host application: unified error handling and exit codes, with no CLI dependency. It does not manage the GC; the first TGocciaEngine constructor on a thread initializes it.
typeTMyApp = class(TGocciaApplication)protectedprocedure Execute; override;end;procedure TMyApp.Execute;begin// Your application logic here; creating an engine initializes the GC// Errors are caught by HandleErrorend;beginExitCode := TGocciaApplication.RunApplication(TMyApp, 'MyApp');end.
What the base class handles:
- Exception dispatch via virtual
HandleError(supportsTGocciaError,TGocciaThrowValue,EGocciaBytecodeThrowwith full source context and colored output) - Exit code management (0 = success, 1 = error, 2 =
TCLIUsageError)
Overridable hooks:
Execute(abstract) — your application logicHandleError(AException)— customize error display (e.g., JSON output)
For CLI tools, use TGocciaCLIApplication instead, which adds argument parsing, help generation, singleton lifecycle management, and a ConfigureCreatedEngine hook where tools attach TGocciaRuntimeCore and apply their chosen runtime profile or runtime extensions.
Minimal Embedding Checklist
- Add
source/units/andsource/shared/to your FreePascal unit search path (or useconfig.cfg) uses Goccia.Runtime, Goccia.RuntimeProfiles.Loader, Goccia.Values.Primitives;- Create
TGocciaRuntime.Create(...)for the runtime layer and file loading - Use
Runtime.Enginefor engine-level options such as ASI, source type, parser diagnostic policy, and compatibility flags - Choose your runtime surface, runtime globals, and
goccia:modules via runtime profiles or runtime extensions - Inject custom globals via
Runtime.Engine.Interpreter.GlobalScope.DefineLexicalBinding(...) - Handle exceptions from
Goccia.Error - Free the runtime when done; it owns and frees the engine only when it created the engine itself, or when you used an ownership-transfer overload such as
TGocciaRuntime.Create(Engine, True).TGocciaRuntime.Create(Engine)is non-owning by default, so embedders wrapping an existing engine must also free that engine.
Prefer virtual modules; global injection remains supported without warnings.