Server subprocess
Launch the native camera server binary from your app, wait for it to be ready, and shut it down cleanly on exit. The Alpha Camera REST API server is a C++ binary that must be running somewhere on the network — usually on the same machine as the camera. This recipe is for apps that want to own the server’s lifecycle.
When to use
Section titled “When to use”- Desktop apps (Electron, Tauri, PyQt, SwiftUI macOS) that should be self-contained — the user shouldn’t have to start a separate server
- CI or test environments that need a camera server per run
- Dev harnesses where you’re iterating on server + client together
When NOT to use
Section titled “When NOT to use”- Anything on iOS — iOS apps can’t spawn subprocesses. Use a server running elsewhere (laptop, Raspberry Pi).
- Browser-only UIs — point at an already-running server
- Server is deployed separately from your app (e.g. always-on Pi)
Where does the binary come from?
Section titled “Where does the binary come from?”CameraWebApp is built from source — see api/distribution/INSTALL_MACOS.md
in the repository. The Sony Camera Remote SDK it links against can only be
obtained by accepting Sony’s licence on their download page, so there is no
package manager that can fetch it for you.
For a self-contained app, ship the binary you built alongside your application:
- Node / Electron: place it in your app’s resources and resolve the path at runtime
- Python: ship it next to your package, or point at it with an environment variable
- Swift / macOS: put it inside your
.appbundle’s Resources
Every example below takes the binary path as input rather than assuming a
location, and falls back to a CAMERA_SERVER_BINARY environment variable.
TypeScript / Node.js
Section titled “TypeScript / Node.js”Complete recipe
Section titled “Complete recipe”// route.ts — starts CameraWebApp as a child process and waits for readiness.
import { NextResponse } from "next/server";import { spawn, type ChildProcess } from "node:child_process";
const BINARY = process.env.CAMERA_SERVER_BINARY ?? "CameraWebApp";const PORT = 8080;
let server: ChildProcess | null = null;let startPromise: Promise<number> | null = null;
async function isHealthyServer(port: number): Promise<boolean> { try { const response = await fetch(`http://127.0.0.1:${port}/api/server/status`, { signal: AbortSignal.timeout(1500), }); return response.ok; } catch { return false; }}
// The binary prints a banner on startup but gives no machine-readable "ready"// signal, so poll the status endpoint rather than parsing stdout.async function waitUntilReady(port: number, timeoutMs = 15000): Promise<void> { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await isHealthyServer(port)) return; await new Promise((r) => setTimeout(r, 250)); } throw new Error(`Server did not become ready on port ${port}`);}
export async function POST() { if (startPromise) { return NextResponse.json({ status: "already_running", port: await startPromise }); }
if (server || (await isHealthyServer(PORT))) { return NextResponse.json({ status: "already_running", port: PORT }); }
try { startPromise = (async () => { const child = spawn(BINARY, ["--port", String(PORT)], { stdio: ["ignore", "pipe", "pipe"], }); child.stdout?.on("data", (d) => console.log("[server]", String(d).trim())); child.stderr?.on("data", (d) => console.error("[server]", String(d).trim())); child.on("exit", () => { server = null; });
await waitUntilReady(PORT); server = child; return PORT; })();
const port = await startPromise; startPromise = null; return NextResponse.json({ status: "started", port }); } catch (error) { startPromise = null; server?.kill("SIGTERM"); server = null; return NextResponse.json( { status: "error", message: error instanceof Error ? error.message : String(error) }, { status: 500 } ); }}
export async function DELETE() { if (!server) { return NextResponse.json({ status: "not_running" }); }
// Ask the server to shut down over HTTP so cameras disconnect cleanly, and // only fall back to a signal if it does not exit. try { await fetch(`http://127.0.0.1:${PORT}/api/server/shutdown`, { method: "POST" }); } catch { /* fall through to the signal */ } server.kill("SIGTERM"); server = null; return NextResponse.json({ status: "stopped" });}// Browser / React sideconst response = await fetch("/api/server", { method: "POST" });const payload = await response.json();
if (payload.status !== "started" && payload.status !== "already_running") { throw new Error(payload.message ?? "Failed to start server");}
const baseUrl = `http://localhost:${payload.port}`;This is the boundary the real Next.js example app uses:
- the route owns the child process
- browser code only talks to
/api/server - the camera lifecycle layer then points your generated client at the returned port
Python
Section titled “Python”Complete recipe
Section titled “Complete recipe”# server_manager.py — spawn, health-check, shutdown.
import asyncioimport osimport signalimport subprocessfrom typing import Optional, Callable
import httpx
class ServerManager: def __init__( self, binary_path: str, port: int = 8080, cwd: Optional[str] = None, on_log: Optional[Callable[[str], None]] = None, ready_timeout_s: float = 15.0, ): self.binary_path = binary_path self.port = port self.cwd = cwd self.on_log = on_log self.ready_timeout_s = ready_timeout_s self.process: Optional[subprocess.Popen] = None self.base_url = f"http://localhost:{port}" self._log_task: Optional[asyncio.Task] = None
async def start(self) -> None: if self.process: raise RuntimeError("ServerManager: already started")
env = {**os.environ, "PORT": str(self.port)} self.process = subprocess.Popen( [self.binary_path], cwd=self.cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, )
# Drain stdout in background so the buffer doesn't fill if self.on_log and self.process.stdout: loop = asyncio.get_event_loop() self._log_task = loop.create_task(self._drain_logs())
# Poll until ready async with httpx.AsyncClient(timeout=1.0) as client: deadline = asyncio.get_event_loop().time() + self.ready_timeout_s while asyncio.get_event_loop().time() < deadline: if self.process.poll() is not None: raise RuntimeError("Server process exited before becoming ready") try: response = await client.get(f"{self.base_url}/api/server/status") if response.status_code == 200: return except httpx.HTTPError: pass await asyncio.sleep(0.25)
# Timeout self.process.terminate() raise RuntimeError("Server did not become ready within timeout")
async def _drain_logs(self): loop = asyncio.get_event_loop() while self.process and self.process.stdout: line = await loop.run_in_executor(None, self.process.stdout.readline) if not line: break if self.on_log: self.on_log(line.rstrip())
async def stop(self) -> None: if not self.process: return
# Graceful shutdown via HTTP first try: async with httpx.AsyncClient(timeout=2.0) as client: await client.post(f"{self.base_url}/api/server/shutdown") except httpx.HTTPError: pass
# Wait for clean exit try: await asyncio.wait_for( asyncio.get_event_loop().run_in_executor(None, self.process.wait), timeout=3.0, ) except asyncio.TimeoutError: self.process.terminate() try: await asyncio.wait_for( asyncio.get_event_loop().run_in_executor(None, self.process.wait), timeout=1.0, ) except asyncio.TimeoutError: self.process.kill()
if self._log_task: self._log_task.cancel() self.process = None
async def __aenter__(self): await self.start() return self
async def __aexit__(self, exc_type, exc, tb): await self.stop()import asyncioimport httpxfrom server_manager import ServerManager
async def main(): async with ServerManager( binary_path="/path/to/CameraWebApp", port=8080, on_log=lambda line: print("[server]", line), ) as server: # Either a client generated from the OpenAPI specification, or plain # HTTP as shown here — the server does not care which. async with httpx.AsyncClient(base_url=server.base_url) as http: listing = (await http.get("/api/cameras")).json() print(f"Found {len(listing['cameras'])} cameras") # ... work ... # server automatically stopped on context exit
asyncio.run(main())Swift (macOS only)
Section titled “Swift (macOS only)”iOS cannot spawn processes. This recipe applies only to macOS (#if os(macOS)).
Complete recipe
Section titled “Complete recipe”// ServerManager.swift — mirrors example_swift_app.
#if os(macOS)import Foundation
public actor ServerManager { public private(set) var baseURL: String
private let binaryPath: String private let preferredPort: Int private let portSearchRange: Int private(set) var port: Int private let cwd: URL? private let onLog: (@Sendable (String) -> Void)? private let readyTimeoutSec: Double private var process: Process?
public init( binaryPath: String, port: Int = 8080, portSearchRange: Int = 10, cwd: URL? = nil, readyTimeoutSec: Double = 15.0, onLog: (@Sendable (String) -> Void)? = nil ) { self.binaryPath = binaryPath self.preferredPort = port self.port = port self.portSearchRange = portSearchRange self.cwd = cwd self.onLog = onLog self.readyTimeoutSec = readyTimeoutSec self.baseURL = "http://localhost:\(port)" }
public func start() async throws { if process != nil { throw ServerManagerError.alreadyStarted }
// Reuse an already-healthy server on the preferred port. if await isHealthy() { onLog?("[server] already running on port \(port); reusing") return }
// If the preferred port is occupied by an orphan CameraWebApp, reap it. // Otherwise, walk forward to the next free port instead of killing // unrelated processes. if let orphanPids = pidsBoundToPort(port), !orphanPids.isEmpty, orphanPids.allSatisfy({ processName(forPid: $0) == "CameraWebApp" }) { onLog?("[server] reaping stale CameraWebApp(s) on port \(port): \(orphanPids)") for pid in orphanPids { kill(pid, SIGKILL) } try? await Task.sleep(nanoseconds: 500_000_000) } else if isPortInUse(port) { var found: Int? = nil for candidate in port..<(port + portSearchRange) { if !isPortInUse(candidate) { found = candidate break } } guard let chosen = found else { throw ServerManagerError.noFreePort(start: port, range: portSearchRange) } if chosen != port { onLog?("[server] port \(port) in use; falling back to \(chosen)") self.port = chosen self.baseURL = "http://localhost:\(chosen)" } }
let p = Process() p.executableURL = URL(fileURLWithPath: binaryPath) p.arguments = [] if let cwd = cwd { p.currentDirectoryURL = cwd } p.environment = ProcessInfo.processInfo.environment.merging( ["PORT": String(port)], uniquingKeysWith: { _, new in new } )
let pipe = Pipe() p.standardOutput = pipe p.standardError = pipe
if let onLog = onLog { pipe.fileHandleForReading.readabilityHandler = { handle in let data = handle.availableData if !data.isEmpty, let text = String(data: data, encoding: .utf8) { for line in text.split(separator: "\n") { onLog(String(line)) } } } }
try p.run() process = p
let deadline = Date().addingTimeInterval(readyTimeoutSec) while Date() < deadline { if !p.isRunning { process = nil throw ServerManagerError.processExitedEarly } if await isHealthy() { return } try? await Task.sleep(nanoseconds: 250_000_000) }
p.terminate() process = nil throw ServerManagerError.startTimeout }
public func stop() async { guard let p = process else { return }
if let url = URL(string: "\(baseURL)/api/server/shutdown") { var req = URLRequest(url: url) req.httpMethod = "POST" req.timeoutInterval = 2.0 _ = try? await URLSession.shared.data(for: req) }
let waitStart = Date() while p.isRunning && Date().timeIntervalSince(waitStart) < 3.0 { try? await Task.sleep(nanoseconds: 100_000_000) }
if p.isRunning { p.terminate() try? await Task.sleep(nanoseconds: 1_000_000_000) if p.isRunning { p.interrupt() } }
process = nil }
private func isHealthy() async -> Bool { guard let url = URL(string: "\(baseURL)/api/server/status") else { return false } var req = URLRequest(url: url) // Fresh server boot can block on camera discovery for several seconds. req.timeoutInterval = 30.0 do { let (_, response) = try await URLSession.shared.data(for: req) if let http = response as? HTTPURLResponse, http.statusCode == 200 { return true } } catch {} return false }
private func pidsBoundToPort(_ port: Int) -> [pid_t]? { let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof") task.arguments = ["-ti", ":\(port)"] let pipe = Pipe() task.standardOutput = pipe task.standardError = Pipe() do { try task.run() task.waitUntilExit() } catch { return nil } let data = pipe.fileHandleForReading.readDataToEndOfFile() guard let out = String(data: data, encoding: .utf8) else { return nil } return out .split(separator: "\n") .compactMap { pid_t($0.trimmingCharacters(in: .whitespaces)) } }
private func isPortInUse(_ port: Int) -> Bool { let sock = socket(AF_INET, SOCK_STREAM, 0) guard sock >= 0 else { return true } defer { close(sock) }
var noReuse: Int32 = 0 setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &noReuse, socklen_t(MemoryLayout<Int32>.size))
var addr = sockaddr_in() addr.sin_family = sa_family_t(AF_INET) addr.sin_port = UInt16(port).bigEndian addr.sin_addr.s_addr = INADDR_ANY.bigEndian
let result = withUnsafePointer(to: &addr) { ptr -> Int32 in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in bind(sock, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } } return result != 0 }
private func processName(forPid pid: pid_t) -> String? { let task = Process() task.executableURL = URL(fileURLWithPath: "/bin/ps") task.arguments = ["-p", String(pid), "-o", "comm="] let pipe = Pipe() task.standardOutput = pipe task.standardError = Pipe() do { try task.run() task.waitUntilExit() } catch { return nil } let data = pipe.fileHandleForReading.readDataToEndOfFile() guard let out = String(data: data, encoding: .utf8) else { return nil } return (out.trimmingCharacters(in: .whitespacesAndNewlines) as NSString).lastPathComponent }}
public enum ServerManagerError: Error { case alreadyStarted case processExitedEarly case startTimeout case noFreePort(start: Int, range: Int)}#endif#if os(macOS)import Foundation
let server = ServerManager( binaryPath: Bundle.main.url(forResource: "CameraWebApp", withExtension: nil)!.path, port: 8080, onLog: { line in print("[server]", line) })
try await server.start()print("Server ready at \(server.baseURL)")
// Either a client generated from the OpenAPI specification, or URLSession// directly — the server does not care which.let (data, _) = try await URLSession.shared.data( from: server.baseURL.appendingPathComponent("api/cameras"))let cameras = try JSONDecoder().decode(CameraList.self, from: data)print("Found \(cameras.cameras.count) cameras")
// ... work ...await server.stop()#endifCommon pitfalls
Section titled “Common pitfalls”- Handle Ctrl-C. If your app gets SIGINT, you need to call
stop()— otherwise the server keeps running. Wire a signal handler or use the context-manager variants (Pythonasync with, Swiftdefer). - Port conflicts. The real macOS app does not assume 8080 is free. It first reuses a healthy server, then reaps stale
CameraWebApporphans, then walks to the next free port. - Stdout buffering. Node’s
spawndoesn’t buffer if you drainstdout. Python needsbufsize=1+ explicit read loop. Swift needs areadabilityHandler. Forgetting these silently stalls the server when its output buffer fills (~64KB). - Don’t kill the server on minor errors. Only call
stop()on app exit or when you actually want it gone. Transient HTTP errors from camera commands aren’t server problems. - Main-actor teardown can deadlock on app quit. The real app snapshots
serverURL+connectedCameraIdand performs shutdown with plainURLSessionoff-actor so AppKit termination does not hang.
Health check before starting
Section titled “Health check before starting”If you want to avoid a port conflict, probe first:
async function isServerAlreadyRunning(port: number): Promise<boolean> { try { const res = await fetch(`http://localhost:${port}/api/server/status`, { signal: AbortSignal.timeout(500), }); return res.ok; } catch { return false; }}
if (await isServerAlreadyRunning(8080)) { console.log("Server already running; skipping spawn");} else { await server.start();}Verified against
Section titled “Verified against”The Swift section mirrors example_swift_app/Sources/AlphaCameraExample/ServerManager.swift and its shutdown path in example_swift_app/Sources/AlphaCameraExample/AppCoordinator.swift.