~15 min · python-worker, pipes, json-lines, code-signing, watchdog
Level 0Idle Hands
0 XP0/39 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Python은 절대 Mac을 조종하지 않아." — Tinker 아키텍처
언어는 둘, 규칙은 한 벌
피파가 다룰 수 있는 가족 앱은 전부 공용 계약 하나로 자기 액션을 선언해. 키트의 AppActions 클래스야. 누가 부를 수 있는지, 호출자 이름을 어떻게 붙이는지, 영수증은 어디 두는지, 어떤 액션에 요청 ID가 필요한지, 답은 얼마나 커도 되는지. 이 계약은 Python으로 한 번 쓰여서 앱마다 그대로 들어가. Tinker는 Swift 앱이야. 계약을 Swift로 다시 쓰면 슬슬 어긋나는 두 번째 사본이 생기고, 아키텍처는 그걸 대놓고 금지해. "No copied reimplementation of the app-door contract." 그래서 Tinker는 진짜 Python 계약을 돌리되, Python이 요청에 대해선 다 정하고 Mac에 대해선 아무것도 못 정하는 모양으로 돌려.
요청 하나에 프로세스 하나
문으로 요청이 올 때마다 상주 에이전트의 BridgeWorker가 Tinker 전용 Conda 환경에서 짧게 사는 Python 프로세스를 띄워. 앱 리소스에 든 worker.py를 돌리고, 물려준 파이프 두 개를 이어. 에이전트는 워커 stdin에 요청을 설명하는 JSON 한 줄을 쓰고, stdout에서 JSON 줄을 읽어. 워커는 AppActions 레지스트리를 만들고 계약 규칙을 적용해. 핸들러가 Tinker 상태나 효과가 필요해지면 아무것도 직접 안 건드려. 액션 이름, 인자, 들여보낸 actor를 담은 call 줄을 찍고 stdin을 읽으며 기다려. 에이전트는 그 호출을 메뉴가 쓰는 바로 그 엔진으로 메인 액터에서 처리하고, 결과를 한 줄로 돌려줘. 워커가 result 줄을 찍으면 교환은 끝이고 프로세스는 나가.
버그였던 작은 디테일
하나하나 돌려보다가 찾은 거야. 워커는 python -B로 띄워. 안 그러면 Python이 번들된 스크립트 옆에 __pycache__를 만드는데, 거기가 서명된 앱의 Resources 안이라서 내용이 바뀐 번들은 봉인과 안 맞게 돼. 줄은 양방향 다 64 KiB로 묶어서, 폭주한 메시지가 메모리를 끝없이 키우지 못하게 해. 에이전트는 자기 쓰기 쪽에서 SIGPIPE를 꺼둬. 워커가 일찍 나가도 에이전트가 죽지 않고 에러가 나게. 읽기는 파이프에 든 만큼 한 번에 가져와서 버퍼에서 줄을 잘라. 첫 버전은 시스템 호출 한 번에 1바이트씩 읽었어. 그리고 떨어져 도는 감시 작업이 75초 넘게 살아 있는 워커를 끝내고, 1초 뒤에도 살아 있으면 죽여. 파이프나 네트워크 호출에 걸린 워커가 문을 붙잡아 두면 안 되니까.
워커가 혼자 해도 되는 것
invoke랑 manifest 말고도, 워커는 에이전트가 직접 묻는 내부 동작 둘에 답해. port는 들어 있는 가족 레지스트리에서 Tinker 포트를 읽고, observe는 Watchfire한테 스냅숏을 물어봐(8트랙). 둘 다 Mac UI는 안 건드려. 화면을 바꾸는 건 전부 call 줄을 타고 Swift로 돌아가.
Code
네이티브 호출의 워커 쪽 (bridge/worker.py에서)·python
import json, sys
from kit_app_actions import AppActionRefused
def emit(value):
print(json.dumps(value, ensure_ascii=False, allow_nan=False), flush=True)
CALLER = {"label": None} # one process serves one invocation
def native(name, args):
emit({"type": "call", "name": name, "args": args, "actor": CALLER["label"]})
line = sys.stdin.readline(65537)
if not line or len(line) > 65536:
raise AppActionRefused("The resident Tinker agent did not answer.")
result = json.loads(line)
if not result.get("ok"):
raise AppActionRefused(result.get("error", "Tinker refused the action."))
return result.get("data", {})
에이전트 쪽: 띄우고, 줄 주고받고, 호출에 답하기 (줄임)·swift
let process = Process(), input = Pipe(), output = Pipe()
process.executableURL = home.appendingPathComponent("miniconda3/envs/cwk-tinker/bin/python")
// -B: a runtime cache inside Resources would invalidate the signed app seal.
process.arguments = ["-B", resources.appendingPathComponent("bridge/worker.py").path]
process.standardInput = input; process.standardOutput = output
guard fcntl(input.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) != -1 else {
throw TinkerError("The app-door pipe could not disable SIGPIPE.")
}
try process.run()
let watchdog = Task.detached {
try await Task.sleep(for: .seconds(75))
if process.isRunning { process.terminate() }
try await Task.sleep(for: .seconds(1))
if process.isRunning { kill(process.processIdentifier, SIGKILL) }
}
defer { watchdog.cancel() }
try send(request)
while true {
let response = try JSONDecoder().decode(Response.self, from: try line())
if response.type == "result" { return response }
guard response.type == "call", let name = response.name, let args = response.args else {
throw TinkerError("Invalid app-door callback.")
}
try send(await dispatch(name, args, response.actor)) // main-actor engine
}
다르게 쪼개 봐. Python 워커를 요청마다 띄우는 대신 오래 사는 데몬으로 둔다면, 얻는 걸 적고, 이 레슨에서 본 보장 중에 다시 따내야 할 걸 전부 적어(서명, 걸린 호출, 뻗음 격리, actor 이름표).
Hint
띄우는 값을 아끼고 레지스트리를 캐시할 수 있어. 대신 서명된 번들을 안 건드리고 업데이트 때 데몬을 안전하게 다시 띄워야 하고, 프로세스를 통째로 죽이는 대신 데몬 안에 요청별 시간 제한을 넣어야 하고, 한 요청의 actor가 다른 요청으로 새지 않게 해야 해(지금 워커가 호출자를 모듈 변수에 둬도 되는 건 프로세스 하나가 호출 하나만 받아서야). 그리고 뻗은 데몬이 pending 실행을 버려두지 못하게 해야 해. 요청마다 프로세스 하나면 그게 다 공짜야.
Progress
Progress is local-only — sign in to sync across devices.