Native boundary: what is contained, and what is not
libpstrainc is vendored C. It reports errors by ending the process: roughly
495 E_FATAL / E_FATAL_SYSTEM / exit / abort sites across 69 of its 157
compiled files, plus the segfaults that malformed input provokes in parsers
that do not check their own reads. None of that is catchable in the calling
interpreter, so any operation that dlopens the library in-process can end a
Python program that merely handed it a bad file.
The containment mechanism is a persistent helper process. It is not the
pipeline’s ProcessPoolExecutor: it is a separate, long-lived child that holds
the library, services one request at a time over a pipe, and is respawned
whenever it dies. Its address space is the blast radius. See
pstrain.lib.native_worker.
Phase contract: contained-all-operations
Supported Python operations implemented by pstrain route their known direct CFFI call
expressions through the helper, except for the decoder described below. The static gate
checks specified syntactic patterns under a reserved lib/_lib name convention: literal
name and attribute-chain calls to the CDEF-derived native surface and loader helpers;
statically resolvable getattr and literal-key dictionary selections; and direct dynamic
getattr, builtins.getattr, __getattribute__, subscript, and explicit __call__ forms
on those reserved handle names. In source order, a plain assignment of an ordinary Python
value to lib or _lib suppresses the convention for expressions visited afterward. This
is a visitor heuristic, not lexical-scope binding analysis.
This is a source-pattern gate, not proof of containment for every possible Python-to-CFFI
call. Annotated assignments, named expressions, chained assignments whose target
expression contains a native call while another target rebinds the handle, and other
binding forms are measured silences. The gate is also silent on multi-step dataflow, attribute binding provenance
(including a Python registry stored as self._lib), function pointers from ffi.addressof,
cross-module aliases, dynamic imports, and semantically equivalent spellings outside the
enumerated patterns.
The shipped PocketSphinx decoder in pstrain.lib.testing.decoder remains in-process and
is used by benchmark, CLI testing, and decode-shard paths; decoder containment is not
certified and decoder behavior is unchanged. This decoder-exemption wording is
documentation-only; the exempt path and enforcement are unchanged.
The original three operations retain their individual protocol names:
Python entry point |
native entry point |
|---|---|
|
|
|
|
|
|
For these three, malformed user input cannot terminate the calling process. Each failure arrives as a typed exception carrying the operation, the input paths, and the diagnostic text the native code wrote to its stderr.
The remaining one-shot wrappers use the generic python_call route. Stateful
BW, alignment and logmath objects live in the helper and are addressed through
opaque object handles. Raw model I/O is exposed only through coarse complete
read/write operations, and feature extraction through its complete operation;
raw C pointers never cross the process boundary. The documented
pstrain.lib._pstrainc.LogMath wrapper is supported; direct access to raw
library handles or symbols through the otherwise private _pstrainc module is
not a supported public operation.
Complete-model value validation
require_complete_model() does not claim that every Python rejection is also a native
parser rejection. Its range and enumeration checks are a conservative subset of observed
native rejection, but its numeric spelling check is deliberately stricter. Native command-line
conversion accepts a numeric prefix and truncates fractional spellings supplied to integer
options (for example, -ncep 13.5 becomes 13). The complete-model boundary rejects such
tokens because accepting them would let the recorded front end differ from the value actually
used. Native parsing and feature-layout acceptance remain authoritative outside this stated
exception.
Across every phase there is one standing rule: one native call at a time per
process. Multi-threaded use of the library is unsupported and undefined —
global_cmdln, the error-callback slot, the allocator’s jump target and some
forty per-program statics are all process-global. Inside a contained operation
this holds structurally, because the helper is single-threaded and services one
request at a time.
Exception hierarchy
PstrainError
└── PstrainNativeError operation, input_path(s), diagnostic, returncode
├── PstrainNativeFatalError helper exited nonzero, or the call reported a diagnosed failure
├── PstrainNativeCrashError helper died on a signal (adds .signal)
├── PstrainWorkerProtocolError helper exited 0 with a request outstanding
└── PstrainInvalidInputError refused by Python-side validation, before any helper ran
PstrainWorkerError helper unstartable, timed out, or died between requests
PstrainNativeError subclasses RuntimeError, so callers that already catch
RuntimeError keep working.
input_path names the file that failed, not the output path.
A clean exit(0) while a request is outstanding is a protocol violation, not
a success. agg_seg’s count path used to do exactly this — write the count
file, then exit(0) with status 0 and no diagnostic on an ordinary first run.
Those two sites are now compiled out of the library build
(PSTRAIN_LIBRARY_BUILD), so control returns to the caller;
PstrainWorkerProtocolError remains as the backstop for any site not yet found.
Lifecycle
One helper per owning process, created lazily on the first guarded call and reused for every call after it. Under the pipeline’s fan-out, each pool worker gets its own helper, so parallelism is not funnelled through a single child.
The
spawnstart method is used explicitly, everywhere. Construction state is picklable.At each complete-operation boundary the helper calls
pstrain_session_reset(), which frees and NULLsglobal_cmdln(whose keys would otherwise accumulate across eight different argument tables) and clears thepstrain_dtreeandpstrain_kmeansmodule statics. Methods on a live stateful object form one coarse operation, so reset occurs when that object closes rather than between its methods. Worker death remains the hard floor: the OS reclaims everything.Any death — signal, nonzero exit, clean exit mid-request, transport EOF — is classified, raised, and followed by a fresh helper on the next call.
Control requests are limited to a 64 KiB serialized payload. Coarse operations that explicitly accept arrays (model I/O and in-memory BW/alignment calls) have a 256 MiB ceiling. One deadline covers both the request send and the response wait; expiry kills the helper so the next call starts fresh. The send is bounded in whichever terms the platform’s pipe offers — a non-blocking descriptor and
selectover the POSIX socket pair, a bounded wait on the overlapped completion event over the Windows message-mode named pipe — so a helper that stops reading cannot park the owning process on either.
No silent fallback
When process isolation cannot be established, guarded work fails; it never
quietly runs in-process instead. The pipeline runner used to fall back to inline
execution when its process pool could not start. Under containment that would be
a silent revert to exactly the unsafe path containment exists to remove, so the
batch is aborted with a PstrainWorkerError instead. The startup probe proves
that the pool can start before task submission, not that every executor worker
has already started. A lazily started worker can still fail after other tasks
complete; that aborts the batch, with completed tasks and their provenance and
completion manifests remaining valid while unfinished tasks remain stale.