Native KiCad PCB IPC¶
Prefer native IPC for live PCB work; Konnect is optional. The tested isolation boundary is one independently launched PCB Editor process per board, with an explicit socket, pinned instance token and verified document path. Keep one design owner. The repository rules still govern task authorisation, source preservation and validation.
This is the default workflow for future live PCB work on macOS: launch an isolated
owned editor with PCBSession.launch(..., hidden=True) through just ipc-python,
retain the instance/document checks for every request, save explicitly and close
only that owned process. Reuse the session within the task. Attach to an existing
editor only when the task specifically needs its live document; do not reuse a
user's editor merely for convenience. Generation, validation, exports and
experiments use disposable build/ copies; authorised source-design edits retain
the exclusive-ownership and verification requirements.
Launch an owned editor on macOS¶
For generation, exports or experiments, first copy the required project and
libraries into a fresh build/ workspace, preserving their relative paths.
Record source hashes before launching and check them on success and failure.
Opening a source board for editing requires an authorised source-design task.
Use PCBSession.launch() through just ipc-python to own the whole lifecycle:
just ipc-python - \
"/absolute/path/to/build/workspace/espurr/espurr-ears/espurr-ears.kicad_pcb" <<'PYTHON'
import sys
from espurr_hw.ipc import PCBSession
with PCBSession.launch(board_path=sys.argv[1], hidden=True) as session:
print("owned PID:", session.owned_pid)
print(session.identity)
print("footprints:", len(session.board.get_footprints()))
# Make authorised changes through session.board / session.transaction(...).
# Call session.board.save() explicitly when those changes should persist.
PYTHON
The factory launches exactly once, finds the new process using a unique inherited
environment receipt and the app's executable, then discovers its actual Unix API
socket with lsof -a -p <PID> -U. It does not adopt an existing process or guess
api-<PID>.sock. It constructs the same checked binding described below and
returns only after the intended full document path is verified. Startup retries
are limited to read-only readiness queries; mismatched documents fail immediately.
The macOS defaults are hidden=True, startup_timeout=30,
shutdown_timeout=5, timeout_ms=2000, and
app_path="/Applications/KiCad/PCB Editor.app". The underlying launch is:
open -n -g -j -a "/Applications/KiCad/PCB Editor.app" --args "/absolute/path/to/board.kicad_pcb"
-n asks LaunchServices for another instance; -g -j requests background,
hidden launch. This is best effort, not a guarantee against focus stealing or
Space changes. The user observed disruptive Space/focus changes despite
AppKit reporting our instances hidden and inactive. PCB Editor had been assigned
to a specific desktop. After the user removed that assignment, a single follow-up
launch caused neither disruption by her observation, with no frontmost-app change
or active-Space notification during the monitored run. That supports desktop
assignment as a likely contributor; it does not establish a general guarantee.
Sampled isHidden, isActive and frontmost-PID values do not
observe Space changes or transient activation. hidden=False omits -j while
retaining -g. This still runs a GUI application with a window server, not a
headless IPC server. Do not change the user's Dock/desktop assignments or macOS
preferences to enforce background behaviour.
For manual experiments, record the exact owned PID and verify its socket owner.
Independently launched editors normally get /tmp/kicad/api-<PID>.sock when
another instance owns api.sock; discover the actual path. A stale socket file
can survive its process and return connection refused. PCBSession.launch()
automates this discovery and ownership tracking; manually launched editors must
be managed separately. Owned launching currently supports macOS only; explicit
socket attachment remains available elsewhere.
Establish and recheck the target¶
Use the official kicad-python IPC bindings (kipy), pinned to a version checked
against the installed KiCad. This is separate from the legacy pcbnew batch
environment. The initial verification used kicad-python==0.8.0 and KiCad 10.0.7.
Use the reusable PCBSession helper through
just ipc-python. Supply both the socket and intended board as absolute paths;
the socket may also use the ipc:///... URI form. For example, after launching
and identifying the owned editor:
just ipc-python - \
"ipc:///tmp/kicad/api-12345.sock" \
"/absolute/path/to/build/workspace/espurr/espurr-ears/espurr-ears.kicad_pcb" <<'PY'
import sys
from espurr_hw.ipc import PCBSession
with PCBSession(socket_path=sys.argv[1], board_path=sys.argv[2]) as session:
print(session.identity)
print("footprints:", len(session.board.get_footprints()))
PY
The helper:
- Requires an explicit local Unix socket and existing board file. It ignores
inherited/default socket and token settings, unless the caller deliberately
supplies a known instance token with
token=.... - Bootstraps through a read-only version query, then pins the returned token.
Its immutable
identityrecords the canonical socket/board paths, token hash and KiCad version without exposing the raw token. - Requires exactly one enumerated PCB and compares its canonical full path and complete document specifier. It checks path/symlink resolution, client socket, pinned token and fresh enumeration before every board request.
- Wraps the IPC client's
sendboundary. Retainedsession.boardobjects and cached bound methods remain guarded. It checks request document specifiers against enumeration, including nested item-operation headers, and transmits a private copy of the validated request. - Invalidates the session after any guarded failure. It never retries an uncertain write, clears a known token or silently accepts another instance. Inspect live/saved state before explicitly constructing a replacement session.
KiCad.get_board() selects the first enumerated board in bindings 0.8.0; do not
use it or construct raw clients/boards around the helper. Binding-specific private
client access is contained inside the helper and must be reviewed when upgrading.
Importing the helper does not connect to KiCad or import native GUI bindings.
The runner pins bindings 0.8.0; the helper refuses other binding versions and
KiCad major versions other than 10.
A responding project-manager endpoint may have no document handler at all.
GetVersion succeeding does not establish PCB readiness. Startup can also return
"KiCad is not ready to reply"; bounded retries are appropriate for these read-only
readiness queries, not for uncertain writes.
The request's project fields are not a server-enforced path guard. In the
verified version, GetItems, UpdateItems and SaveDocument accepted wrong
project names/directories. Wrong instance tokens and board filenames were
rejected for updates and saves. The client must check the path returned by
enumeration itself; socket/token isolation and document verification work
together. Copied boards can have identical item UUIDs as well as identical names.
Mutate, save and verify¶
Use a small native undo transaction: begin_commit(), the required item
operations, then push_commit(). Prefer session.transaction(message), which
yields the guarded board and checks these boundaries automatically. It cancels
with drop_commit() on a caller-side exception only while the binding remains
valid. A guarded IPC failure invalidates the session and suppresses automatic
rollback; inspect the editor before recovery. Nested transaction contexts are
refused. Neither transaction exit nor session close implicitly saves.
In bindings 0.8.0 the begin/end commit requests have no document argument. Keep the one-board-per-owned-process boundary. The path check is not an atomic compare-and-swap with the following write; exclusive design ownership also requires that nobody switch the document underneath the operation.
After committing, read back the exact affected UUIDs and properties from the
guarded session.board. Call session.board.save() (also guarded), then verify
the saved file independently. A successful API response alone is insufficient.
Preserve pad/net mappings and run the relevant DRC/parity/export checks for the
actual design change.
save_as() is refused because it can overwrite a different board. Requests
without a checked document are refused, except the reviewed process-scoped
begin/end commit and ping requests. Project APIs and unscoped convenience
operations are not generally supported. Extending that allowance requires
review and tests; do not bypass the guard to make a rejected call work.
API errors, busy responses, disconnects and transport timeouts also invalidate the bound session. There is no automatic write/recovery loop. The launch factory's readiness polling ends before the bound session is returned. Lifecycle behaviour depends on whether the session launched the editor or attached to an existing one; see below.
For independent legacy native reads on macOS, use
just kicad-python and finish file/GUI-resource cleanup explicitly.
The isolation probe uses one native reader worker per saved board after a
multi-board reader encountered an opaque SWIG wrapper. Never rewrite a live
editor's backing file with batch APIs. Copy the saved file to a separate reader
input before calling legacy pcbnew.LoadBoard; even loading can affect native
lock files. Native reserialization must also write a separate disposable output.
For isolation tests, hash all candidate board/project/library files around each save; only the intended board may change. Also check every editor's live state, the independent saved readbacks and the original source hashes. Keep failed-run evidence until any uncertain operation has been resolved.
Session lifecycle¶
PCBSession(socket_path=..., board_path=...) attaches to an explicit editor.
Closing it closes only its IPC connection. owned_pid is None; it never adopts
process ownership.
PCBSession.launch(board_path=...) owns the new editor. close() and context
exit terminate only that process, including on a body exception or uncertain API
failure. Saves remain explicit: unsaved editor changes are discarded on owned
shutdown. For failure investigation, opt into keep_open_on_error=True; a body
exception then closes the client but retains the editor. Keep the session object
and call session.close() when inspection is done. Startup failures always
attempt owned cleanup, regardless of this option. No destructor or atexit hook
provides cleanup; use a context manager or try/finally.
Shutdown verifies the PID's start-time/executable signature and unique launch receipt before sending SIGTERM. After the configured wait, escalation to SIGKILL requires another ownership check. Ambiguity or changed ownership prevents signalling; cleanup errors are raised or attached as notes to the original exception. There is no bundle-wide quit, process-name kill or fallback to another editor. Other editors remain outside the session's ownership. Do not switch a managed editor to a different document or share it with another design owner.
Launch refuses pre-existing native PCB/project lock markers, including stale ones, before opening a GUI. It never overrides or deletes those locks. Managed sessions also reserve their canonical board/project paths with process-held file locks, preventing two managed launches from racing on the same paths. Other boards remain available. These reservations do not coordinate unmanaged editors; exclusive design ownership is still required.
KiCad 10.0.7 leaves native ~<filename>.lck markers after process termination;
its lock contents identify only host/user. This caused the observed "already
open in another editor" popup during development. The helper records lock
fingerprints after launch, refreshes them after verified binding and successful
guarded save/revert, and removes only unchanged task-created markers after the
owned process exits. Changed markers are retained and reported. Never delete a
lock just because it exists or names the current user. Stale API sockets are not
proof of a live editor, and sockets owned by other processes are left alone.
Keep the immutable session.identity and owned_pid in run evidence. Avoid
logging raw tokens or inherited process environments; reports use token hashes.
Verified on 2026-10-31¶
KiCad 10.0.7 on macOS, with official kicad-python 0.8.0, was exercised without
Konnect. Three standalone editors opened disposable project copies: two identical
left-ear boards in different directories and one microphone flex. An existing
project manager remained running and untouched.
The read-only pass verified distinct socket owners/tokens, correct full-path enumeration, matching saved footprint identities and 30 alternating targeted reads. The subsequent write pass verified:
- Native creation, text/position updates and removal of a unique
Cmts.Usertext marker on each board, using native undo transactions. - Twelve successful saves: create, edit, deliberately wrong-project update/save, and removal for each target. Every save changed only the intended board file; all other copied board/project/library files retained their byte hashes.
- Thirty-six independent saved-board readbacks, checking all three boards after every save. Each read recorded marker UUID/text/layer/position, removed only the test marker in memory, then serialized to a separate comparison file through native APIs. All remaining content matched its native-serialized baseline.
- Twelve rejected requests: foreign-instance-token updates/saves and wrong-board- filename updates/saves on each editor. Live marker state and saved hashes were unchanged by the rejected requests.
- Deliberately wrong project names/directories accepted by both update and save on all three editors. These negative probes intentionally bypassed the client's document-specifier guard on disposable copies; normal work must not do this.
- Final removal restored all three original board files byte-for-byte. All 191 protected source, library and frozen-record hashes were preserved.
- Before/after refilled DRC with schematic parity and all-track reporting had
identical finding records. The ear copies each retained 217 DRC warnings
(18 library-footprint differences and 199 silk-over-copper warnings), zero
errors/unconnected items and one U1
JLCPCB_CORRECTIONfield-parity warning. The mic copy had no findings. This was an isolation check, not a readiness review.
The first saved-readback worker failed while loading multiple boards with an opaque SWIG wrapper; the pending live and saved edit was inspected before recovery. Switching to one reader worker per board and normalizing the user-facing layer name resolved the verifier issues without replaying the first write. The complete run then passed, and only the three owned test editors were terminated.
Local evidence is retained at
build/ipc-write-isolation-20261031/: inputs.json, write-report.json,
native snapshots, before/after DRC reports and the Ruff-checked probe/readback
helpers. Earlier read-only evidence is at
build/ipc-multi-instance-20261031-113700/ and
build/ipc-instance-audit-20261031-113458/. These ignored paths are supplementary
local evidence; the verified method and limitations are recorded here durably.
Coverage is limited to isolated standalone PCB editors and board-text operations. This does not verify simultaneous editors inside one process, restart/PID-reuse recovery, every item class, routing, schematic IPC or unrelated KiCad versions. Revalidate the relevant capabilities when changing versions or extending the workflow; keep the binding and independent-readback requirements.
Reusable helper verification¶
The same three-editor arrangement was exercised through PCBSession and the
just ipc-python runner. Nine create/update/remove saves changed only their
intended board, with 27 independent native saved-board readbacks and identical
before/after refilled DRC/parity records. All three final board files matched
their original byte hashes. Fifteen live negative probes rejected wrong targets,
wrong tokens, modified document specifiers, invalidated-session reuse and
save_as attempts without changing saved files or other live markers.
The full fast suite passed 99 tests, including cached-reference checks, ambiguous enumeration, token/socket/document drift, symlink retargeting, uncertain replies, transaction cancellation and runner/import boundaries. The live test does not exercise every native Board method; unsupported unscoped requests fail closed.
Evidence is retained at build/ipc-helper-verification-20261031/, including
report.json, inputs.json, cad-protection.json, native comparison snapshots,
DRC records and Ruff-checked probe helpers. The original broad preservation
sentinel stopped on a concurrent BX breakout README edit. That failure record
was preserved; the pending saved edit was inspected and its readback completed
without replaying it. Verification then used the original, unchanged hashes for
186 CAD/library/frozen-input paths, excluding five unrelated breakout Markdown
documents. Concurrent documentation work was preserved without restoration or
rebasing the CAD hashes. Only the three owned test editors were terminated.
The shared preservation guard was subsequently narrowed to hardware inputs as documented in tooling. New work should use that scope directly instead of a whole-tree sentinel that includes unrelated prose.
Owned launch and shutdown verification¶
PCBSession.launch() was exercised with three simultaneous disposable editors:
two identical ear basenames in separate directories and a microphone flex board.
PID/socket/token identities were distinct. A native marker save changed only the
first ear board; nine independent saved-board snapshots across baseline, saved
marker and removal matched all other content. Removal restored all three boards
byte-for-byte. Closing the first owner left both peer editors alive and passing
fresh document checks. Attaching and closing a second client did not terminate
its editor.
Normal context exit, body-error cleanup with an unsaved marker, explicit cleanup
after keep_open_on_error=True, and a forced startup timeout all closed only the
owned process. The body-error path did not implicitly save. Native lock cleanup
allowed successive reopenings without overriding locks. All protected input
hashes were unchanged; band boards were never opened by this test, and concurrent
band work was excluded from the preservation sentinel. The staged project trees
include unused band files as part of the original complete snapshot.
AppKit reported all five successfully bound launch instances hidden and inactive at the observation points, with the same frontmost PID. This did not establish non-disruptive launch: the user reported Space/focus changes. No further GUI batch was launched after that report. A later single-launch follow-up after desktop unpinning is recorded below.
Evidence is at build/ipc-owned-launch-20261031/: report.json, inputs.json,
probe.log, nine snapshots/verified-* readbacks and Ruff-checked helpers.
Distinct failed-run reports preserve the native lock popup, lock refresh and
process-exit observer findings. The observer now treats macOS E (exiting) and
Z states as terminated rather than requiring immediate PID disappearance.
These checks cover lifecycle and board-text isolation, not every GUI dialog or
focus/desktop transition. The fast suite separately covers receipt ambiguity,
PID reuse, changed ownership, lock replacement, same-path reservation, bounded
shutdown escalation and failed binding cleanup. The complete fast suite passed
127 tests. Scoped Ruff checks and formatting passed for the implementation,
tests and saved verification helpers.
Single launch after desktop unpinning¶
After the user removed PCB Editor's assignment to a particular desktop, exactly
one PCBSession.launch() opened a fresh disposable ear copy, using the unchanged
default hidden=True path. It bound the intended board and read its 106 footprints,
remained open for 15 seconds after binding, then closed its owned process.
The user confirmed neither focus stealing nor Space switching occurred.
A main-thread AppKit run loop observed
NSWorkspaceActiveSpaceDidChangeNotification from before launch through shutdown,
and sampled the frontmost PID every approximately 50ms. It recorded no active-Space
notifications or frontmost-PID changes. The copied board and 64 protected
source/library files were unchanged, the owned process exited and its native
locks were absent. Band boards were never opened. This is one successful trial,
consistent with the desktop assignment contributing to the earlier disruption;
retain the best-effort launch limitation for other circumstances.
Local evidence: build/ipc-unpinned-single-launch-20261031/report.json,
probe.log, the Ruff-checked probe.py and its disposable project copy.