배포가 18분짜리 작업을 죽였다 — 돌던 일을 안 죽이는 데몬 재시작
맥릴레이라는 걸 만들고 있다. 폰에서 맥의 클로드코드나 커서에 일을 시키는 앱이다. 실행은 맥에서 하고, 화면은 폰으로 본다. 맥에서는 launchd가 띄운 데몬이 돌면서 폰에서 온 말을 받아 claude나 커서 CLI(agent)를 자식 프로세스로 띄우고, 답을 다시 폰으로 올린다.
구조는 대충 이렇다.
[폰/브라우저] ⇄ WSS ⇄ [Cloudflare Worker + 중계] ⇄ WSS ⇄ [맥 데몬 → claude -p | agent -p]이 글은 그 데몬을 "다시 켜는" 이야기다. 별것 아닌 것 같은데, 생각보다 따질 게 많았다.
무슨 일이 있었나
2026년 10월 3일. 커서에 맡긴 턴 하나가 18분째 돌고 있었다.
그 사이 다른 대화에서는 데몬 코드를 고치고 배포를 했다. 이 프로젝트는 데몬 코드가 바뀌면 배포 끝에 tools/restart-daemon.sh로 데몬을 다시 띄우게 되어 있다. 그래서 그 대화는 시킨 대로 데몬을 다시 켰다.
그리고 18분 돌던 커서 턴이 사라졌다. 오류 답도 없이. 폰 쪽에서 보면 그냥 답이 안 오는 대화가 하나 남은 거다.
에러가 났으면 에러를 봤을 텐데, 아무 말도 없으면 "아직 도나?" 하고 기다리게 된다. 말없이 죽는 게 제일 나쁘다.
왜 죽었나
당시 restart-daemon.sh는 이렇게 생겼었다.
#!/bin/sh
# 맥 데몬을 N초 뒤에 다시 띄운다(기본 30초) — 지금 이 대화가 macrelay 를 거쳐 오고 있어도 답이 폰에 닿은 뒤에 끊기게.
# 바로 kickstart 하면 데몬이 자식(claude)을 같이 죽여 지금 턴의 답이 사라진다. 그래서 떨어진 셸이 기다렸다가 launchd 에 맡긴다.
DELAY="${1:-30}"
nohup sh -c "sleep $DELAY; launchctl kickstart -k gui/$(id -u)/com.macrelay.daemon" >/dev/null 2>&1 &
echo "맥 데몬을 ${DELAY}초 뒤에 다시 띄운다"주석을 보면 나도 문제를 반쯤은 알고 있었다. 데몬을 바로 죽이면 데몬이 띄운 자식(claude)도 같이 죽는다. 그래서 30초를 기다렸다. 배포를 하고 있는 바로 그 턴의 답이 폰에 닿을 시간만큼.
함정은 "지금 이 턴"만 생각했다는 거다. 데몬은 대화 하나만 돌리지 않는다. 다른 대화의 턴, 커서 턴, 줄 서서 기다리는 말까지 같이 들고 있다. 30초 뒤 kickstart -k는 그걸 전부 가리지 않고 끊는다. 지금 보면 너무 당연하다.
그러니까 문제는 두 겹이었다.
- 언제 끌지를 시간(30초)으로 정했다. 일이 끝났는지가 아니라.
- 끊긴 턴에 아무 답도 남기지 않았다. 그래서 "말없이" 죽었다.
어떻게 바꿨나
방향은 단순하다. 바깥에서 데몬을 죽이지 말고, 데몬에게 "하던 일 다 끝나면 나가라"고 부탁한다. 다시 띄우는 건 원래대로 launchd가 한다.
신호를 나눴다: SIGUSR2는 "끝나면 나가", SIGTERM은 "지금 나가"
지금 restart-daemon.sh는 먼저 ~/.macrelay/daemon.pid를 본다. 새 데몬은 켜질 때 이 파일에 <pid> graceful을 적어 둔다.
writeFileSync(join(HOME, 'daemon.pid'), `${process.pid} graceful\n`);스크립트는 graceful 표시가 있고, 그 pid가 실제로 daemon/index.mjs로 살아 있을 때만 SIGUSR2를 보낸다.
if [ -f "$PIDF" ] && grep -q graceful "$PIDF"; then
PID=$(cut -d' ' -f1 "$PIDF")
if kill -0 "$PID" 2>/dev/null && ps -p "$PID" -o command= | grep -q 'daemon/index.mjs'; then
kill -USR2 "$PID"
echo "맥 데몬이 하던 일을 다 끝내면 다시 켜진다 (pid $PID)"
exit 0
fi
fi표시가 없으면, 즉 이 기능이 들어가기 전의 옛 데몬이면 예전처럼 N초 뒤 kickstart로 떨어진다. 스크립트 주석에도 "다른 대화의 턴은 끊긴다"고 그대로 적어 뒀다. 넘어가는 기간 한 번은 어쩔 수 없다.
pid만 보고 바로 쏘지 않고 ps로 명령줄까지 확인하는 부분은, 내가 읽기엔 pid 파일이 낡아 그 번호가 엉뚱한 프로세스에 재사용됐을 때를 막는 장치다.
"다 끝났다"의 정의
여기가 이 작업의 핵심이다. 데몬이 나가도 되는 순간이 정확히 언제인가.
SIGUSR2를 받으면 데몬은 기다리기 시작한 시각(restartAt)만 적고, 1초마다 확인한다.
process.on('SIGUSR2', () => {
if (!restartAt) { restartAt = Date.now(); log(`다시 켜기를 기다린다 · 도는 일 ${busyCount()}개`); }
stopDeep();
});
setInterval(() => {
if (!restartAt || leaving) return;
if (!busyCount() && !pending.size && !jotting) leave('다시 켜기');
else if (Date.now() - restartAt > RESTART_MAX) leave(`다시 켜기 — ${RESTART_MAX / 60e3}분을 기다려도 일이 안 끝났다`);
}, 1000);나가는 조건은 세 가지가 동시에 비는 것이다.
- 도는 턴과 줄 선 말 —
busyCount(). 엔진이 일하는 중이거나, 막 끝난 턴의 답을 내보내는 중(closing)이거나, 큐에 다음 말이 있으면 센다. - 방이 받았다고 안 한 답 —
pending. 데몬이 보낸 답은 중계 서버가 받았다는 확인이 올 때까지pending에 남아 있다. 턴이 끝났다고 바로 나가면, 그 마지막 답이 서버에 닿기 전에 프로세스가 사라질 수 있다. - 받아 오는 중인 한 줄 —
jotting. 코드 주석에 따르면 "배포하는 턴의 한 줄이 늘 마지막에 걸린다". 배포를 시킨 턴 자신이 끝나면서 한 줄을 받아 오는 중이라, 이걸 안 기다리면 결국 배포한 대화가 마지막 순간에 잘린다.
언뜻 1번만 있으면 될 것 같다. 턴이 다 끝나면 나가면 되지 않나. 그런데 "턴이 끝남"과 "답이 폰에 닿음" 사이에는 틈이 있고, 사용자 입장에선 그 틈에서 죽는 거나 중간에 죽는 거나 똑같다. 2번과 3번은 그 틈을 메우려고 붙었다.
이 세 개가 비면 leave()로 나가고(exit 0), launchd의 KeepAlive가 새 데몬을 띄운다.
기다리는 동안 들어온 새 말은?
이게 은근히 까다로웠다. 다시 켜기를 기다리는 동안에도 폰에서는 새 말이 올 수 있다. 이걸 받아서 돌리기 시작하면 데몬은 영영 못 나간다. 버리면 사용자의 말이 사라진다.
그래서 받지 않고 "남겨 둔다". 데몬은 방(중계 서버)의 줄 번호 중 어디까지 처리했는지를 state.json의 lastSeq로 들고 있는데, 기다리는 동안 온 프롬프트에서는 이걸 올리지 않는다.
if (body?.kind === 'prompt' && restartAt) {
if (!held) { held = true; ev({ kind: 'note', text: '맥 쪽 프로그램을 곧 다시 켜요. 이 말은 다시 켜진 뒤 바로 시작해요', ok: true }); }
heldSeq = row.seq; return;
}
if (!held) { state.lastSeq = row.seq; writeJSON('state.json', state); }lastSeq가 그 앞에 묶여 있으니, 새 데몬은 켜지자마자 방에서 그 말들을 다시 받아 돌린다. 원래 이 데몬은 꺼져 있던 동안 온 프롬프트를 켜질 때 전부 도는 구조라서, 그 성질을 그대로 빌려 썼다. 폰에는 "곧 다시 켜요, 이 말은 다시 켜진 뒤 바로 시작해요"라는 안내가 한 번 뜬다.
한 가지 예외가 있다. 질문에 대한 답(reply)은 막지 않는다. 지금 도는 턴이 권한 질문 같은 걸 띄우고 멈춰 있다면, 그 답을 받아야 턴이 끝나고, 턴이 끝나야 데몬이 나갈 수 있다. 이걸 막으면 데몬은 자기가 기다리는 걸 자기가 막고 있는 꼴이 된다. 그리고 주석에 따르면 새 데몬이 그 답을 다시 받으면 "지난 질문"으로 버린다. 그러니 지금 받아야 한다.
그 밖에도 기다리는 동안은 새 일을 벌이지 않는다. 앱 안의 비서가 스스로 새 대화를 열려고 하면 "맥 쪽 프로그램을 다시 켜는 중"이라며 막고, 사람이 기다리지 않는 백그라운드 작업은 SIGUSR2를 받자마자 접는다.
30분 상한
무한정 기다리지는 않는다. RESTART_MAX는 30분이다. 이걸 넘기면 그냥 나간다.
이 숫자는 좀 의견이 갈릴 수 있다고 생각한다. 18분짜리 턴 때문에 시작한 일인데, 30분 넘는 턴은 결국 끊긴다는 얘기니까. 다만 배포가 영영 반영되지 않는 것도 문제고, 데몬은 별도로 stdout이 기본 20분 조용하면 턴을 끊는 장치도 있어서, 일단 이 정도로 두었다.
그래도 못 막는 경우와 대비
SIGUSR2는 내가 보내는 신호다. 세상에는 내가 고를 수 없는 종료가 더 많다.
SIGTERM: 지금 나가야 할 때, 적어도 말은 남긴다
launchctl kickstart -k나 데몬을 끄는 경우 SIGTERM이 온다. 이건 기다릴 수 없다. 대신 말없이 죽지는 않게 했다.
process.on('SIGTERM', () => leave('SIGTERM'));
process.on('SIGINT', () => leave('SIGINT'));leave()가 하는 일을 순서대로 보면 이렇다.
- 막 끝난 턴이 답을 내보내는 중이면 잠깐(몇 초) 기다려서 그 답은 나가게 둔다.
- 도는 턴에는 지금까지 쌓인 글자를 담아 "맥 쪽 프로그램이 다시 켜지느라 하던 일이 끊겼어요. 고친 파일은 그대로 남아 있어요. 다시 보내면 여기서 이어서 해요." 라는 오류 답을 보낸다.
- 줄 서 있던 말에는 "이 말은 아직 전하지 못했어요. 다시 보내 주세요"라고 답한다.
- 엔진 프로세스들을 정리하고,
state.json의live를 비운다. - 방이 답을 받았다고 할 때까지 최대 5초 기다린 뒤 나간다. 코드 주석에는 "launchd 는 20초를 준다"고 적혀 있다.
이 오류 문구는 코드에 원칙이 적혀 있다. 무슨 일이 있었는지, 내 작업은 안전한지, 지금 뭘 하면 되는지. 세 가지만. 18분 작업이 끊긴 사람한테 제일 궁금한 건 "내 파일 날아갔어?"일 거라서, "고친 파일은 그대로"를 꼭 넣었다.
10월 3일에 이게 있었다면, 적어도 18분 뒤에 영영 오지 않는 답을 기다리진 않았을 거다.
전원 차단·SIGKILL: 다음에 켤 때 알린다
SIGKILL이나 갑자기 전원이 나가면 leave()조차 못 돈다. 신호 핸들러가 불리지 않으니까.
그래서 미리 적어 둔다. 턴이 시작될 때마다 state.json의 live에 그 턴을 기록하고, 턴이 끝나면 지운다.
/** 도는 턴을 state.json 에 적어 둔다 — 데몬이 갑자기 죽어도 다음에 켤 때 그 대화에 "끊겼어요"를 남기려고 */
function markLive(sid, e) {
state.live ||= {};
if (e) state.live[sid] = { ref: e.ref || null, project: e.project, at: Date.now() };
else if (state.live[sid]) delete state.live[sid];
else return;
writeJSON('state.json', state);
}정상적으로 나갔다면 live는 비어 있다. 그러니 데몬이 켜졌는데 live에 뭔가 남아 있다면, 그건 지난번에 턴이 도는 채로 죽었다는 뜻이다. 켜질 때 그 대화마다 같은 "끊겼어요" 답을 보내고, live를 비운다.
// 지난번에 도는 채로 죽은 턴 — 답이 영영 안 오게 두지 않는다
for (const [sid, l] of Object.entries(state.live || {})) {
msg(sid, { kind: 'answer', sid, ref: l.ref, text: '', cost: 0, ms: Date.now() - l.at, error: CUT });
...
}SIGTERM 때와 달리 여기선 지금까지 쓰던 답(text)이 비어 있다. 그건 메모리에만 있다가 같이 사라졌으니까. 그래도 "답이 영영 안 오는 대화"는 없어진다. 이 경로가 얼마나 자주 쓰일지는 모르겠다. 하지만 한 번이라도 쓰이면 값을 한다고 본다.
정리하면
결국 종료를 세 단계로 나눈 셈이다.
- SIGUSR2: 다 끝날 때까지 기다렸다가 나간다. 그동안 온 말은 새 데몬에게 넘긴다.
- SIGTERM: 지금 나가지만 끊긴 턴마다 말을 남긴다.
- 전원·SIGKILL: 아무것도 못 하지만, 미리 적어 둔
live로 다음에 켤 때 알린다.
어느 단계든 공통된 건 하나다. 말없이 끝나는 턴을 남기지 않는다.
아직 고민 중인 것도 있다. "다 끝날 때까지"라는 게 사람이 계속 일을 시키면 영영 안 끝날 수 있는데, 지금은 새 프롬프트를 묶어 두는 걸로 피하고 있다. 그런데 이미 줄 서 있던 말이나, 권한 질문에 답하는 대로 계속 이어지는 긴 턴은 결국 30분 상한에 기대게 된다. 이게 적당한지는 좀 더 써 봐야 알 것 같다.
혹시 에이전트를 데몬으로 돌리고 있다면, 배포 스크립트의 재시작 한 줄을 한번 다시 보길 권한다. 나는 그 한 줄이 "지금 이 대화"만 지키고 있었다는 걸 18분짜리 작업을 날리고 나서야 알았다.
