Authenticated loopback control endpoints for Lisp image daemons.
The library defines the wire protocol shared by a detached Lisp image and the terminals controlling it: bounded length-prefixed readable packet frames with reader evaluation disabled, capability tokens, timestamp-bearing session identifiers with legacy compatibility, and deadline-bounded loopback connection and call primitives. Load the optional runtime for private endpoint discovery, persistent request services, terminal relay ownership, bounded replay and attachment client loops.
- ASDF system:
image-daemon - Optional SBCL runtime system:
image-daemon/runtime - Optional SBCL evaluation endpoint system:
image-daemon/eval - Test system:
image-daemon/tests - Package:
IMAGE-DAEMON
Install the locked dependencies and run all tests:
./script/bootstrap
./script/check(image-daemon:daemon-call port token ':status)
(multiple-value-bind (socket stream)
(image-daemon:daemon-connect port)
(image-daemon:daemon-write-packet stream packet)
(image-daemon:daemon-read-response stream ':attach))Packets are proper lists written readably and framed by one decimal
character count. Reading rejects reader evaluation, unbalanced
payloads, and frames above *DAEMON-PACKET-CHARACTER-LIMIT*. Every
required response is bounded by *DAEMON-CONNECT-TIMEOUT-SECONDS*.
Request frames keep the historical :LOCALGROUP-REQUEST tag for
compatibility with deployed endpoints.
Create an endpoint with DAEMON-RUNTIME-CREATE and a request callback. The default
mode publishes a discovery record and requires a registry directory and identifier.
Pass :PUBLISH-P NIL for an unpublished ephemeral runtime; it needs neither and
keeps no registry pathname. After your own startup transaction, call
DAEMON-RUNTIME-START. Stop and unpublish a published runtime with
DAEMON-RUNTIME-STOP. The callback receives (runtime request &key socket stream)
after protocol and capability validation; write the response with the packet API.
You may subclass DAEMON-RUNTIME and specialize
DAEMON-RUNTIME-REQUEST-VALID-P and DAEMON-RUNTIME-ERROR-RESPONSE for an
application protocol.
Use DAEMON-REGISTRY-DISCOVER to inspect complete discovery records. Registry
writers serialize with a directory file lock; publication retains live or
uncertain ownership unless the capability matches. Reconciliation deletes only
definitely dead records that still equal the inspected value. Shutdown likewise
compares the complete record, preserving an intervening replacement.
Create a RELAY with RELAY-CREATE. Implement the TRANSPORT- protocol for your
foreground transport, or specialize it on a relay subclass to synchronize your
UI’s state. RELAY-ATTACH queues the handshake and replay before live output,
allows multiple observers and one controller, and revokes the former controller
on takeover. Read semantic packets with RELAY-READ-ATTACHMENT. Resizes run under
the relay lock, so a host resize method should publish to its UI thread without
reacquiring that lock. Hosts decide when input readers may pause and whether
releasing a foreground terminal requires process handoff.
ATTACHMENT-CREATE owns an asynchronous output writer. Pending output and replay
have independent character limits. On overflow, shut down socket I/O before
abortive stream close and join the writer with a bounded wait. The client-side
DAEMON-ATTACH-CLIENT-RUN accepts input-readiness, event-read and resize callbacks;
supply its socket or a shutdown callback that unblocks concurrent reads.
Pass :packet-function to handle host-specific control packets in the receiver
thread. Return true from that callback to end the attachment.
Attachment handshakes include :history-start and :history-position character
positions for replaying output produced during a reconnect.
When the hosted application is about to exit with a status its launcher acts on,
call RELAY-FINISH with that status and an optional message. Every client
receives it after the output already queued for it, and DAEMON-ATTACH-CLIENT-RUN
returns it as (:STATUS STATUS :MESSAGE MESSAGE), so a relaying front process can
exit exactly as a foreground application would have. Any other end returns NIL.
A process that hands its work to a detached replacement does it through a
private ticket. handoff-ticket-pathname names one beneath a directory and
handoff-ticket-write publishes the host’s record, which carries :state and
:token. The replacement claims the ticket with handoff-ticket-claim, a
rename that fails once the launcher has cancelled, and keeps checking
handoff-ticket-owned-record while it starts. The launcher cancels with
handoff-ticket-cancel, a rename the replacement then sees. Every state lives in
a sibling named after the ticket (handoff-ticket-sibling), so
handoff-ticket-delete removes the whole family.
handoff-launch-supervised starts the replacement behind a Bash supervisor that
creates its process group, records the group identifier beside the ticket
(handoff-ticket-launcher-pid), and only then lets the launcher run, so a failed
handoff can always find what to stop. handoff-ticket-replacement-pid names the
claimant. daemon-endpoint-status accepts a registry record only when a
different process serves it with the expected token and answers an
authenticated :status request, and daemon-wait-until polls such a check up to
a deadline.
image-daemon/eval lets trusted local programs evaluate Common Lisp in the
running image. eval-endpoint-create validates the settings: a private Unix
socket or an IPv4 loopback TCP address, a token file, the package sources are
read and evaluated in, and bounds on frames, sources, output, queued requests,
clients and time. eval-endpoint-start listens and starts one accept thread
and one serial evaluator; eval-endpoint-stop closes everything within a
deadline and signals :quiesce naming any thread that stayed alive.
Every frame is a four-octet big-endian length followed by that many UTF-8
octets holding one S-expression. The length is checked before the payload is
read, and the payload is parsed by a closed sexp-config grammar, so no frame,
authenticated or not, interns a symbol or nests without bound. A connection
starts with (:challenge :version 1 :algorithm :hmac-sha-256 :nonce HEX) and
must answer (:authenticate :proof HEX), the HMAC-SHA-256 of the nonce keyed by
the token file’s content. The endpoint reads the token only while checking a
proof, refuses a token file that is not a private regular file, wipes every
secret octet, and retains only the pathname. Authenticated clients then send
(:evaluate :source TEXT) one at a time and receive an :evaluation-result with
bounded values, captured output, and a :condition or :timeout status for
failures, including debugger entry.
A Unix endpoint refuses an active socket and any object it does not own; a
stale socket is renamed aside and removed only while it is still the socket
that was inspected. eval-connect and eval-call are a complete client, and
eval-proof computes a proof for clients written elsewhere.
(let ((endpoint (image-daemon:eval-endpoint-create
:transport ':unix
:unix-pathname #p"/run/user/1000/app/eval.sock"
:token-pathname #p"/home/me/.config/app/eval.token"
:package "MY-APP")))
(image-daemon:eval-endpoint-start endpoint))Failures signal DAEMON-ERROR carrying a message, the failing
operation, and the session identifier when known. Hosts may substitute
a subclass through *DAEMON-ERROR-CLASS* to join their own condition
hierarchy. Evaluation endpoints signal EVAL-ENDPOINT-ERROR, a
DAEMON-ERROR that adds a non-secret reason keyword and, for :quiesce, the
names of the threads that did not stop.
Thread stops use one deadline across graceful exit and forced termination. If
unwind cleanup cannot finish, handle DAEMON-ERROR with operation :STOP-THREAD.
Recover the target with SB-THREAD:THREAD-ERROR-THREAD on DAEMON-ERROR-CAUSE,
release the blocked cleanup, and retry DAEMON-STOP-THREAD. Attachment and runtime
owners retain their thread references when a stop fails.
COLL-Attribution. See LICENSE.lisp.