Skip to content

feat: single-instance app via QLocalServer IPC#108

Merged
nelsonduarte merged 5 commits into
mainfrom
feat/single-instance-ipc
Jun 28, 2026
Merged

feat: single-instance app via QLocalServer IPC#108
nelsonduarte merged 5 commits into
mainfrom
feat/single-instance-ipc

Conversation

@nelsonduarte

Copy link
Copy Markdown
Owner

Summary

When the user opens a PDF from Explorer/Finder while PDFApps is already running, the second invocation now forwards the path(s) to the running instance (which loads them as new tabs and raises its window), instead of spawning a duplicate process.

Closes R8 H4/H5 — the highest-value architectural gap from the audit roadmap.

Architecture

  • app/single_instance.py (new, ~170 LOC):

    • _socket_name() — stable per-user, sha256(getuser)[:12]-prefixed name to avoid collisions on shared machines.
    • send_to_existing(paths) — second-invocation connector. Length-prefixed UTF-8 wire format (4-byte big-endian header + payload), 64 KB cap, NUL-delimited paths. Returns True on successful delivery (caller exits).
    • SingleInstanceServer — QObject listening on QLocalServer, emits new_paths signal. Stale-socket cleanup via QLocalServer.removeServer on bind. Defensive defaults: fail-open on bind failure (warn + continue), graceful UTF-8 decode failures, sanity-capped payloads.
  • pdfapps.py:main: tries send_to_existing() BEFORE creating QApplication. If hit, exits cleanly. If miss, falls through to normal startup. No QCoreApplication probe needed — QLocalSocket works standalone.

  • app/window.py: MainWindow.__init__ instantiates SingleInstanceServer(self) and connects new_paths to _on_second_instance, which validates each path (defensive re-check), calls _load_and_track, and brings the window to the front (showNormal if minimized + raise_ + activateWindow — cross-platform).

Security

  • Socket is per-user (UNIX domain / Windows Named Pipe), no inter-user surface.
  • Receiver re-validates each path with os.path.isfile + .lower().endswith('.pdf') — never trusts the peer.
  • 64 KB payload cap prevents accidental DoS.
  • UTF-8 decode failures handled gracefully.
  • NUL separator instead of newline (filenames with embedded newlines no longer split silently).

Adversarial review

The review caught a critical bug in the original implementation: del _probe_app does NOT destroy the Qt singleton, only releases the Python reference. The subsequent QApplication(sys.argv) raised RuntimeError when the user double-clicked a PDF in Explorer with PDFApps not already running — i.e., the most common second-invocation scenario crashed cold-start.

Fix (commit 261abef): removed the probe entirely. QLocalSocket works without any Qt application instance (verified). Also tightened with NUL separator + 3 new tests for _read_payload guards (oversized header, zero-length header, non-UTF-8 payload) + 1 regression test asserting the probe is absent.

Tests

tests/test_single_instance.py12 tests, all passing:

  • 7 wiring/source-level (socket naming, payload format, QtNetwork usage, etc.)
  • 1 behavioural (send_to_existing returns False without server)
  • 1 regression (cold-start without probe)
  • 3 guard tests for _read_payload

End-to-end (spawn 2 processes) intentionally omitted — fragile in CI. Manual QA at install time confirms integration.

Validation

  • 5 atomic commits
  • pytest unchanged baseline + 12 new
  • No APP_VERSION bump
  • Compatible with all merged PRs (A through L + security pins)
  • No new external dependencies (PySide6.QtNetwork already bundled)

Out of scope

  • E2E spawn test (fragile in CI; manual QA covers it)
  • Apple App Store sandbox compatibility (not distributing through App Store)
  • Single-instance for non-PDF args (only PDF files trigger the forward, by design)

nelsonduarte and others added 5 commits June 28, 2026 12:43
New app/single_instance.py exposes:
- _socket_name() — stable per-user, sha256(username)-prefixed
- send_to_existing(paths) — second-invocation connector
- SingleInstanceServer — listener QObject with new_paths signal

Length-prefixed UTF-8 wire format with 64 KB cap. Stale socket
cleanup via QLocalServer.removeServer on bind.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main() now tries send_to_existing() before creating QApplication.
If the call succeeds, the second invocation exits with code 0,
leaving the running instance to load the PDFs. If no instance is
running, falls through to normal startup.

A throw-away QCoreApplication drives the QLocalSocket event loop
during the probe; it is torn down before QApplication is constructed
to satisfy Qt's single-app-singleton invariant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MainWindow.__init__ instantiates SingleInstanceServer and connects
its new_paths signal to a handler that loads each PDF as a new tab,
brings the window to the front (raise_/activateWindow/showNormal),
and bypasses the second-instance Explorer flow entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Seven tests covering:
- _socket_name() determinism and per-user hash naming
- send_to_existing() returns False gracefully when no server is up
  (skips cleanly if a dev PDFApps instance is running)
- module uses QtNetwork primitives
- payload length is capped (DoS guard)
- pdfapps.main() calls send_to_existing BEFORE QApplication(sys.argv)
- MainWindow wires SingleInstanceServer + raises/activates on event

Behavioural end-to-end tests would require subprocess + QLocalServer
choreography that is fragile in CI; manual QA covers the integration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Round-11 review caught that `del _probe_app` does NOT destroy the
Qt singleton — only releases the Python reference. The next
`QApplication(sys.argv)` then raised `RuntimeError: Please destroy
the QCoreApplication singleton before creating a new QApplication
instance.` when the user double-clicked a PDF in Explorer with
PDFApps not already running (the most common second-invocation
scenario where send_to_existing returns False and we fall through
to normal startup).

QLocalSocket.waitForConnected/waitForBytesWritten work standalone
without any Qt application instance, so the probe is unnecessary —
removed entirely.

Also from the same review:
- Use NUL (\0) as the path separator instead of newline. Paths on
  macOS/Linux can legitimately contain newlines, which the previous
  \n-joined payload silently split into multiple bogus paths. NUL
  is illegal on every mainstream filesystem.
- Add regression test pinning that main() never reintroduces a
  QCoreApplication probe.
- Add coverage tests for the _read_payload guards (oversized header,
  zero-length header, non-UTF-8 payload) and for the NUL separator
  on both sender and receiver sides.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@nelsonduarte nelsonduarte added the enhancement New feature or request label Jun 28, 2026
@nelsonduarte nelsonduarte merged commit e0be397 into main Jun 28, 2026
3 checks passed
@nelsonduarte nelsonduarte deleted the feat/single-instance-ipc branch June 28, 2026 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant