9월 3일, Kkumi와 CalmNest에서 tuist generate와 출시 레인이 "Resolving package dependencies"에서 더 나아가지 않았다. xcodebuild -list -workspace도 같은 자리에서 멈췄다. 프로세스는 살아 있었지만 CPU 누적 시간은 1초가 안 됐고 로그는 한 줄도 늘지 않았다. 계산을 하는 게 아니라 무언가를 기다리고 있었다.
같은 문구, 다른 원인
7월에도 같은 문구에서 멈춘 적이 있다. 그때는 SPM이 Firebase와 광고 SDK를 git으로 받다가 인증 프롬프트를 띄운 게 원인이었다. fastlane이나 백그라운드 실행처럼 입력을 받을 수 없는 환경에서는 프롬프트가 답을 영원히 기다린다. github.com 응답은 0.06초였고 git ls-remote도 됐는데 xcodebuild의 해석만 멈췄으니 네트워크 문제는 아니었다. GIT_TERMINAL_PROMPT=0을 주면 git이 프롬프트를 띄우는 대신 바로 실패하고, SSH는 BatchMode=yes로 같은 효과를 낸다.
그때 하나를 더 배웠다. 멈춘 해석 프로세스를 pkill -f로 정리하면 일부가 살아남고, 남은 프로세스 여러 개가 SPM 캐시 잠금을 두고 서로를 기다린다. PID를 하나씩 kill -9로 죽이고 ps로 남은 개수를 확인해야 했다.
그래서 이번에도 프롬프트를 먼저 의심했지만, 환경 변수를 줘도 풀리지 않았다.
범위를 좁힌 순서
- 원격 SPM 의존성이 하나도 없는 앱에서도 멈췄다. SPM 문제가 아니었다.
-workspace대신-project로 열면 4초에 끝났다. 워크스페이스를 여는 과정이 문제였다.- 같은 프로젝트를 참조하는 워크스페이스를 같은 폴더에 새 이름으로 만들면 정상이었다.
- 기존 워크스페이스 번들을 지우고 같은 경로에 다시 만들면 또 멈췄다. 파일(inode)이 아니라 경로에 무언가 걸려 있었다.
- 멈추는 앱은 최근에 빌드를 강제로 종료한 앱뿐이었고, 다른 앱은 정상이었다.
- DerivedData 삭제, 3주째 떠 있던 Xcode 보조 프로세스 종료는 효과가 없었다.
sample로 본 스택
멈춘 프로세스를 sample <pid>로 떴다.
-[IDEWorkspace initWithFilePath:...]
-[DVTFilePath performCoordinatedReadRecursively:]
-[NSFileCoordinator _blockOnAccessClaim:withAccessArbiter:]
semaphore_wait_trap
Xcode는 워크스페이스를 열 때 NSFileCoordinator로 조정된 읽기를 요청하고, 같은 경로에 먼저 걸린 클레임이 있으면 그게 풀릴 때까지 기다린다. 강제 종료된 빌드가 워크스페이스 경로에 클레임을 남겼는데 filecoordinationd가 이를 놓지 않았고, 그 경로를 여는 프로세스는 모두 세마포어에서 멈췄다. 클레임이 경로 단위라는 점도 앞의 실험 결과와 맞았다.
filecoordinationd는 SIP로 보호돼서 sudo killall로도, 관리자 권한 AppleScript로도 죽지 않았다. 재부팅하니 풀렸다.
우회를 먼저 쓴 대가
재부팅 전에 폴더 이름을 바꿔 경로를 피했다. 워크스페이스는 바로 열렸고, 옆 폴더의 공용 패키지를 가리키는 상대 경로도 그대로 동작했다. 문제는 이름을 되돌릴 때 생겼다. tuist는 Package.resolved를 절대 경로 심볼릭 링크로 만들어 두기 때문에, 원래 이름으로 돌리자 링크가 끊겼고 다음 tuist generate가 Link already exists로 실패했다. 링크를 지우고 다시 생성해야 했다.
같은 세션에서 에이전트가 앱 12개의 DerivedData를 지우고 시스템 보조 프로세스를 종료한 것도 미리 알리지 않은 작업이었다. 이후 폴더 이름 변경, 시스템 데몬 종료, 재부팅 요청, 공유 캐시 일괄 삭제는 실행 전에 확인을 받고, 근본 해결이 있으면 우회보다 먼저 제안하도록 에이전트 규칙에 넣었다.
45시간 동안 끝나지 않은 테스트
9월 26일에는 FocusMind 테스트 전체를 돌린 xcodebuild test가 45시간 동안 끝나지 않았다. 알림 예약 경합을 재현하는 async 테스트 하나가 돌아오지 않았는데, xcodebuild는 기본적으로 테스트 실행 시간에 제한을 두지 않는다.
-test-timeouts-enabled YES -maximum-test-execution-time-allowance 60을 붙이면 그 테스트만 60초 뒤 실패로 처리되고, xcodebuild가 테스트 러너를 다시 띄워 나머지를 끝까지 돌린다. -quiet는 어디서 멈췄는지 감추기 때문에 빼고 로그를 파일로 남긴다. 테스트 자체의 경합은 아직 고치지 못했고, 지금은 시간 제한으로 격리만 해 둔 상태다.
멈추지는 않지만 같은 계열
- 빌드 도중 디스크를 비우려고 DerivedData를 지우면
SDKStatCaches.noindex같은 캐시가 사라져 진행 중인 빌드가stat cache file오류로 죽는다. 7월에 겪고 9월에 같은 실수를 한 번 더 했다. - 출시 레인 3개와 컴파일 확인 13개를 동시에 돌렸더니 여유 공간 9GB가 몇 분 만에 0이 됐다. 업로드 도구는 패키지가 손상됐다는 오류(90168)로, 빌드는 모듈 의존성을 찾을 수 없다는 오류로 실패했는데 어느 메시지도 디스크를 가리키지 않았다. 앱 하나를 아카이브할 때 앱끼리 공유하는 ModuleCache만 3GB 가까이 쓴다. 지금은 일괄 빌드를 순차로 돌리고, 앱마다 시작 전에 여유 공간이 3GB 미만이면 멈춘다.
- 디스크를 비우려고 쓰지 않는 시뮬레이터 런타임을 지웠더니, Watch 앱이 들어 있는 OnePercent의 아카이브가 "watchOS 26.5 must be installed"로 실패했다. 아카이브는 실기기용이지만 스킴에 Watch 타깃이 있으면 그 플랫폼의 런타임을 요구한다. 3.7GB를 다시 받았다.
아직 코드로 막지 못한 것
세 가지 멈춤 가운데 코드로 막은 것은 아직 없다. GIT_TERMINAL_PROMPT=0과 테스트 시간 제한은 명령을 실행할 때 붙이는 규칙으로만 남아 있고, 공용 Fastfile이나 테스트 절차에는 들어가 있지 않다. 다음에 할 일은 이 둘을 공용 레인에 넣는 것이다.
같은 레인에서 코드로 막은 사례는 하나 있다. 앱 폴더에 Deliverfile이 남아 있으면 바이너리 업로드를 조용히 건너뛰어, 레인은 성공으로 끝나는데 빌드는 올라가지 않았다. 원인을 찾는 데 오전이 두 번 들었고, 지금은 레인이 그 파일을 발견하면 바로 실패한다.
