Development¶
git clone https://github.com/nerima-lisp/cl-process-kit.git
cd cl-process-kit
nix develop # SBCL + a C compiler, CL_SOURCE_REGISTRY preconfigured
nix flake check # build the native trampoline, run the full test suite
The Nix flake pins the tested cl-weave, cl-boundary-kit, cl-log-kit,
cl-date-kit, cl-concurrent-kit, cl-host-kit, cl-tty-kit, and
cl-codec-kit versions and configures the Common Lisp source registry, so
nix flake check is the preferred way to run the suite. Inside nix
develop, sbcl --script run-tests.lisp is also available with the pinned
dependencies.
nix flake check is the authoritative gate: it runs in the same sandboxed
environment CI uses, and covers the test suite (checks.default), the PTY
integration suite (checks.pty-tests), Nix formatting (checks.formatting),
and the mkdocs --strict build (checks.docs). A change that only passes a
local, non-sandboxed sbcl --script run-tests.lisp is not fully verified.
Making a change¶
Beyond the org-wide CONTRIBUTING guide, three expectations are specific to this repository.
- Add or update tests for any behaviour change.
t/mutation-test.lispandt/property-test.lispare the examples to follow: they test a contract rather than a single case. src/coverage is a ratchet, not a report. The+minimum-expression-coverage+/+minimum-branch-coverage+floors inrun-tests.lispfail the build on a regression, and are only ever raised — see Coverage below.- Prefer a structural refactoring tool such as
paredit-cliover hand-editing balanced-parenthesis code when reshaping existing forms.
Record user-visible changes in the GitHub Release description when the tag is published; it is the only canonical release history. The release workflow opens an empty draft release for exactly that.
Beyond example-based describe/it/expect tests, the suite uses
cl-weave's it-property for
value-space invariants (for example: run reports exactly the requested
exit code for every code in [0, 255]), with-mocked-functions for
isolating slow or non-deterministic collaborators, it-each for
table-driven guard-clause tests (each malformed-input row is its own
independently-reported case, not one aggregate pass/fail), and
run-mutations/assert-mutation-score for mutation testing
(process-success-p/pipeline-success-p's case battery must kill every
one-operator mutation of the live function body, not just execute every
line -- coverage alone cannot prove that).
t/package.lisp also registers three cl-weave:defmatchers over
process-result/pipeline-result -- :to-have-succeeded,
:to-have-timed-out, and :to-have-been-cancelled -- via a small
%define-result-state-matcher macro shared across all three, so a test
reads (expect result :to-have-succeeded) rather than
(expect (process-success-p result) :to-be-truthy). Reach for one of these
whenever an assertion is checking a result's terminal state as a plain
boolean; write the raw predicate call directly, as mutation-test.lisp and
property-test.lisp do, only where the assertion compares against a
dynamically computed value rather than asserting a fixed known state.
Running the suite on both platforms¶
The suite currently reports 232 tests. All of them run on macOS; seven are
it-skipped on Linux under #+linux, each a case asserting that a process
group is gone within a 0.1s grace period. Timing a contended shared CI runner
cannot reliably deliver, so t/package.lisp's +suite-complete-p+ records
the skips for diagnostics. run-tests.lisp nevertheless enforces its
coverage floors on every supported platform for the executable code that ran;
a timing skip is not a reason to disable the non-regression gate.
Running on both platforms is worth the effort, because process semantics
diverge exactly where this library works: signal delivery, process-group
reaping, and whether closing a descriptor interrupts a thread already blocked
reading it (macOS/BSD do; Linux does not). Two real defects shipped in 0.2.0
were invisible on macOS and reproducible on Linux — see 1.0.0's ### Correctness
notes in the
release notes.
Two habits follow. Never name a program a guard-clause test does not intend
to execute: /bin/true does not exist on macOS, so a
(signals error (run "/bin/true" ... :bad-option)) there passes on the
launch failure whether or not the guard exists. Resolve fixtures through
PATH with the %true-program/%spawn-sleeping helpers in t/package.lisp
instead. And reach for a #+linux it-skip only for a test whose assertion
is unsound on that platform, never for one catching a real difference in the
library — skipping the latter hides the failure from CI without making the
library any less broken there.
CI runs Linux. To check locally before pushing, run the suite in a container:
docker run --rm -v "$PWD/..":/src -w /src/cl-process-kit \
-e CL_SOURCE_REGISTRY="/src//" clfoundation/sbcl:2.6.1-bookworm \
sh -c 'cc -O2 -o /tmp/spawn native/spawn.c &&
CL_PROCESS_KIT_SPAWN=/tmp/spawn sbcl --script run-tests.lisp'
This assumes the sibling nerima-lisp dependency checkouts live alongside
this one, which is what mounting the parent directory as /src provides.
Testing the PTY backend¶
nix flake check runs checks.pty-tests alongside the core suite, so it
needs no separate invocation under Nix. Outside Nix, build the native PTY
library, then run:
Coverage¶
Set CL_PROCESS_KIT_COVERAGE=1 to additionally recompile src/ under
SB-COVER instrumentation and print an expression/branch coverage report
after the suite runs (and save the raw data to coverage.dat):
It is off by default because instrumentation forces a full recompile and
adds per-form bookkeeping overhead that a normal nix flake check / CI run
shouldn't pay for.
The current instrumented run reports 88.1% expression coverage and 82.6%
branch coverage for src/. The +minimum-expression-coverage+ and
+minimum-branch-coverage+ values in run-tests.lisp are non-regression
floors and should only be raised. The remaining forms include compile-time
macro and defstruct definitions, defensive OS syscall failures, and
platform-specific cleanup branches; add focused seams or tests before
raising the floors.
Source layout¶
src/ is organized by concern rather than as one large file per public
entry point:
| File | Holds |
|---|---|
types.lisp, conditions.lisp |
Pure data shapes and the condition hierarchy |
parameters.lisp |
Tunable defaults and the cl-boundary-kit clock/sleeper boundary defaults |
logging.lisp |
The optional cl-log-kit observability hook |
command.lisp |
command-spec/cancellation validation and accessor logic |
spawn.lisp |
The low-level spawn/spawn-command primitive, wrapping sb-ext:process into a process-handle; run is built on top of it |
process-handle.lisp, process-group.lisp, communication-state.lisp |
The low-level process-handle lifecycle |
capture.lisp, copier.lisp |
Output capture and the stream-copier/feeder threads |
communicate.lisp |
The cancellation-aware communicate |
async-events.lisp |
The event-queue/dispatch machinery behind the asynchronous API (ring buffer, process-event submission, and the dispatcher) |
async-task.lisp |
The process-task accessors and communicate-async/await-process/cancel-process entry points that drive it |
run.lisp |
The synchronous run/run-command entry points |
pipeline.lisp |
run-pipeline |
native-spawn.lisp |
The spawn-native trampoline: CLI-flag translation, the launch-error pipe protocol, and native-process-launch-error |
The optional cl-process-kit/pty system adds package-pty.lisp and
pty.lisp (the PTY backend's alien routine declarations
and pty-process operations) as a separate :pathname "src" component
list in cl-process-kit.asd, kept out of the core system's
dependency/build footprint. Its defpackage stays out of
src/package.lisp so loading the core system never defines a package
whose symbols have no code behind them; the test-side package, which has
no such constraint, lives in t/package.lisp with the rest.
t/ mirrors that split:
run-test.lispcoversrun/run-command's non-timeout behavior (environment/directory, stdio, output capture and encoding).run-timeout-test.lispcovers timeout/SIGTERM->SIGKILL escalation and cancellation-token handling, split out ofrun-test.lisponce it grew past 380 lines across five thematically distinct suites.spawn-test.lispcovers rawspawnprocess lifecycle, process-handle streams,communicateresult caching, structured logging, process-group termination, input validation, and executable resolution.process-handle-test.lispcoversprocess-handlebookkeeping.pipeline-test.lispcoversrun-pipeline.async-task-test.lispcoverscommunicate-async/run-command-async/the event cursor API.lifecycle-test.lispcovers the shared executor threads: none at load time,shutdown-process-kit, saving an executable, and forking.conditions-test.lispasserts each condition's:reportoutput.logging-test.lispbinds*process-logger*and checks the lifecycle records.validation-test.lispdrives everymake-command/spawn-nativeguard clause as acl-weave:it-eachtable (one independently-reported case per malformed-input row) plus the native decoders directly.native-spawn-test.lispcoversspawn-native's Lisp-level launch and typed-error paths;native-spawn-test.shis a standalone shell script (invoked directly bynix flake check, not throughrun-tests.lisp) that drives the compiled trampoline binary's full CLI surface — fd mapping, fd passing,--chdir,--session,--detached,--rlimit,--umask, and the 8-byte launch-error record — without a Lisp process in the way.pty-test.lisp(in the separatecl-process-kit/pty-testsystem, run viarun-pty-tests.lisp) covers the PTY backend: controlling session/foreground process group, resize, raw octet transfer, EOF, foreground-only signaling, and timeout escalation.edge-coverage-test.lispexercises the reachable branch edges the behavioral suites skip — stream-valued:input,process-waittimeout expiry, the at-most-oncecommunicatecontract, UTF-8 surrogate/overlong replacement, and (viacl-weave:with-mocked-functionsfault injection) copier-thread failure.property-test.lispstates value-space laws withcl-weave:it-propertygenerators (an octet round trip throughcat, argument preservation, the NUL-rejection guard, the success predicate), which cl-weave shrinks to a minimal counterexample on failure.mutation-test.lispmutation-testsprocess-success-p/pipeline-success-pwithcl-weave:run-mutations, reading eachdefunbody live fromsrc/command.lispon every run so the case battery can never silently drift out of sync with the implementation it is checking.
Threads, saved images, and fork¶
Loading cl-process-kit must not start a thread: SBCL refuses
save-lisp-and-die and sb-posix:fork while a second thread runs, so a
thread started at load time breaks every consumer that builds an executable
or forks. The worker pools in async-task.lisp are created on first use and
stopped by shutdown-process-kit; see
Worker threads and shutdown.
Create any new long-lived thread the same way, make shutdown-process-kit
stop it, and add its name to +kit-thread-names+ in
t/lifecycle-test.lisp. Per-call threads (copier and feeder threads in
copier.lisp, communicate.lisp, and pipeline.lisp) are joined before
their call returns and need no shutdown. The save test deliberately saves
without calling shutdown-process-kit, so it exercises the
sb-ext:*save-hooks* registration.
Conventions¶
Argument validation across the library is written with the %ensure guard
macro (the assert-style (%ensure test control-or-class ...) counterpart
of a two-line unless/error pair), and with-process is thin syntax
over the exported continuation-passing call-with-process, so the
resource-cleanup contract lives once as a function.
Building this documentation site¶
builds this MkDocs (Material) site in --strict mode offline, so a broken
internal link fails the build the same way CI would catch it. For local
iteration: