Skip to content

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:

  1. 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=....
  2. Bootstraps through a read-only version query, then pins the returned token. Its immutable identity records the canonical socket/board paths, token hash and KiCad version without exposing the raw token.
  3. 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.
  4. Wraps the IPC client's send boundary. Retained session.board objects 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.
  5. 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.User text 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_CORRECTION field-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.