Skip to content

Open the window before the daemon. - #19

Merged
im-ian merged 2 commits into
mainfrom
window-before-daemon
Sep 21, 2026
Merged

im-ian merged 2 commits into
mainfrom
window-before-daemon

Conversation

@im-ian

@im-ian im-ian commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

TL;DR

데몬이 어떤 이유로든 못 뜨면 앱이 창도 없이 종료되던 걸, 창은 무조건 띄우고 실패 이유를 창 안에서 보여주도록 바꿔요. #17 이 원인 하나(빈 로스터)를 없앴다면, 이건 증상 자체를 없애요.

증상

agents.json 이 깨져 있으면 Finder 에서 아이콘만 한 번 튀고 아무것도 안 열려요. #17 머지 후에도 그대로 재현돼요.

$ CREW_HOME=/tmp/crewt crew            # {"agents": [,]}
crew: daemon did not start. log tail:
crew: parse /tmp/crewt/agents.json: expected value at line 1 column 13
$ echo $?
1

원인

desktop::run() 이 tauri::Builder 보다 먼저 client::ensure_daemon()? 를 호출하고, 실패하면 main() 이 exit(1) — 창이 만들어지기 전이에요. 다른 호출부는 전부 에러를 UI 에 문자열로 넘기는데 run() 만 프로세스를 죽여요.

고친 것

창은 무조건 뜬다

  • ensure_daemon() 과 ensure_home() 둘 다 에러를 반환하지 않고 기록만 해요. run() 안에서는 더 이상 ? 를 쓰지 않아요.
  • 시작은 여전히 run() 에서, 여전히 한 번 — 창만 결과와 무관하게 만들어져요.
  • ensure_home 을 직접 부르는 이유: 홈 디렉터리를 만들던 게 ensure_daemon() 이었고, setup 의 write_ui_pid() 는 실패를 삼켜요. 첫 실행에서 ui.pid 가 안 써지면 창이 열려 있는데도 OS 알림이 떠요.

이유를 들고 있다가, 해결되면 잊는다

  • 데몬 없는 창은 cannot connect to <socket> 밖에 말할 수 없어요. 구멍이 있다는 말이지 왜 생겼는지가 아니에요. 시작 실패 사유를 저장해 뒀다가 daemon_ping 이 그걸로 답해요.
  • 데몬이 한 번이라도 응답하면 그 사유를 지워요. 안 지우면 몇 시간 뒤의 끊김에도 이미 고친 설정 파일을 탓해요.
  • mutex poisoning 시에도 사유를 잃지 않아요 (unwrap_or_else(into_inner) — paths::testing 의 기존 패턴).
  • 번들 .app 은 stderr 를 버려서, 사유를 crew.log 에도 남겨요.
  • daemon_ping 이 Result<()> 를 반환해요. Pong 이 아닌 모든 결과가 에러라 bool 의 false 는 도달 불가였고, 그게 예전 Ok(_) => Ok(false) 가 "정상 연결"로 읽히던 원인이에요.

창 안에서 보여준다

  • ConnectionBanner 를 UpdateBanner 옆 grid row 에 놓아요 — 덮지 않고 아래로 밀어내요. 슬롯은 항상 마운트돼 있어서 role="status" 가 실제로 읽혀요.
  • 헤딩은 사용자 언어(conn.down, 한/영), 데몬의 영문 원문과 로그 tail 은 그 아래에. 원문이 비어 있으면 아예 안 그려요 (빈 빨간 막대 방지).
  • connected === false 로 판정해요. 초기값이 null(아직 확인 전)이에요.
  • connDetail / connected 는 폴링만 씁니다. showError 가 봇 이름 빈칸 같은 로컬 검증 실패에도 데몬이 끊긴 걸로 만들고 있었고, tickMessages 가 200ms 마다 진짜 이유를 덮어썼어요. 지금껏 아무 데도 렌더 안 돼서 안 보였을 뿐이에요.
  • 대신 tickMessages 는 잡은 에러를 console.error 로 남겨요. get_messages 는 삭제된 봇에도 Error 로 답해서, 통째로 삼키면 스레드가 옛 메시지에 멈춘 채 흔적이 없어요.

검증

  • cargo test --locked -p crew — 211 passed (ping_failure 단위 테스트 포함: 기록된 사유가 소켓 에러를 이기고, 클리어 후엔 안 이김)
  • npm test — 130 passed / npm run build (tsc 포함) 통과
  • cargo fmt --check — desktop.rs clean
  • 실제 실행으로 두 실패 경로 모두 창 유지 확인:
A) 깨진 agents.json      -> app=ALIVE,  "parse /tmp/crewt/agents.json: expected value at line 1 column 13"
B) CREW_HOME 이 일반 파일 -> app=ALIVE,  "create /tmp/crewfile: File exists (os error 17)"
  • 25초 관찰: 데몬 시작 시도 총 2회로 고정(증가 없음), 새 crew daemon 프로세스 0개
  • 고치기 전(main) 같은 설정: EXITED (no window)

남긴 트레이드오프

ensure_daemon() 은 여전히 Builder 앞에서 동기로 돌아요. 정상 실행에선 즉시 반환하고, 데몬이 안 뜰 때만 최대 4초 지연 뒤 창이 떠요. 백그라운드 스레드로 옮기면 창은 즉시 뜨지만, client::rpc 로 직행하는 command 들(send_message, get_messages 등 ~15개)이 데몬이 준비되기 전에 실패할 수 있어요. 4초 뒤 쓸 수 있는 창 과 즉시 뜨지만 잠깐 깨진 창 중에 전자를 택했어요.

자동 재연결도 뺐어요. 설정을 고친 뒤엔 앱 재시작이 필요해요 — 폴링에서 데몬을 띄우는 접근은 메인 스레드 blocking, 데몬 중복 기동, 좀비 누적을 불러서 접었어요 (아래 참조).

범위 밖 (리뷰에서 나온 기존 문제)

  • list_agents 등 12개 command 가 sync 인 채로 ensure_daemon() 을 호출해요. 데몬이 없는 창에서 "새 봇" 같은 동작을 하면 4초간 UI 가 멈춰요. 이 PR 이 그 상태를 도달 가능하게 만들지만, 고치려면 12곳을 async 로 옮겨야 해서 따로 가는 게 맞아요.
  • ensure_daemon 의 cmd.spawn() 이 Child 를 reap 하지 않아요.
  • crew.log 에 회전이 없어요.
  • daemon::run 이 소켓을 bind 하기 전에 전체 로스터를 spawn 해요.

@im-ian
im-ian force-pushed the window-before-daemon branch from 1caa221 to 7531fb5 Compare September 21, 2026 03:25
`desktop::run` returned the daemon's start error before building
anything, so a daemon that would not start took the whole app with it.
From Finder that reads as the icon bouncing once and nothing opening —
the same symptom for a config this binary cannot parse, a socket it
cannot bind, or any reason yet to come, with no window left to fix it
from.

The start still happens there, and still once: the window is built
whether or not it worked. Leaving it where it is keeps every command
that goes straight to `client::rpc` finding a daemon the way it always
has, and keeps the start off the poll — `ensure_daemon` waits out a
start deadline, `daemon::run` opens every agent before it binds, and the
twelve commands that call `ensure_daemon` from the main thread all
assume it is rare.

`run` calls `ensure_home` itself now. It was reached through
`ensure_daemon`, and `setup` writes `ui.pid` into that directory and
swallows the failure — on a first launch the pid would go unwritten and
the window would notify as if it were closed.

A window with no daemon could only report `cannot connect to <socket>`,
which describes the hole and not what made it, so the start failure is
kept and `daemon_ping` answers with it. `Event::Error` carries its own
message now too, the way every sibling command already returns it.

The reason reaches the user through a banner in the grid row beside
`UpdateBanner`, which pushes the thread down rather than covering it.
`connected` and `connDetail` have been computed since the poll was
written and never rendered, so nothing yet depended on who set them:
`showError` marked the daemon down for a blank bot name, and
`tickMessages` overwrote the reason every 200ms. The poll owns them.
@im-ian
im-ian force-pushed the window-before-daemon branch from 7531fb5 to 63cba96 Compare September 21, 2026 03:38
From review of #19.

`ensure_home` still used `?`, so a home directory that cannot be created
exited with no window — the one thing this PR claims cannot happen any
more. It records its reason like the daemon start does, and names the
path it could not create.

The recorded reason was never dropped. A daemon started later, by hand
or by any command that calls `ensure_daemon`, left it in place; a
disconnect hours afterwards then blamed a config file the user had
already fixed. `daemon_ping` clears it as soon as a daemon answers.

Poisoning the mutex threw the reason away and left the bare socket error
this exists to replace. `unwrap_or_else(|e| e.into_inner())`, the way
`paths::testing` already does it.

A bundled .app discards stderr, so the reason also goes to `crew.log`.

`daemon_ping` returns `()`. Every non-Pong outcome is an error now, so
the bool advertised a false case that could not happen — which is what
let the old `Ok(_) => Ok(false)` read as a live connection.

The banner leads with a line in the user's language; the daemon's own
English text and log tail follow it, and are left out entirely when
empty rather than painting a bare red bar. `tickMessages` logs what it
catches — `get_messages` answers `Error` for a removed agent too, and
swallowing that froze the thread on stale messages with no trace.
@im-ian
im-ian merged commit 0c82568 into main Sep 21, 2026
2 checks passed
@im-ian
im-ian deleted the window-before-daemon branch September 21, 2026 03:53
@im-ian im-ian mentioned this pull request Sep 21, 2026
5 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant