요약
startPreviewServer의 pkill -f 'npx serve'가 실제 serve 프로세스를 한 번도 매치하지 못한다. 그 결과 프리뷰를 다시 띄워도 옛 serve가 포트 3000을 계속 쥐고 있어 낡은 빌드 산출물이 서빙된다. 사용자에게는 "고쳤는데 프리뷰가 그대로"로 보인다.
근거
PreviewWorkspaceService.java:144-151
String buildDir = detectBuildOutputDir(containerId);
dockerService.exec(containerId, "pkill -f 'npx serve' 2>/dev/null || true");
dockerService.exec(containerId, "nohup npx serve -s " + buildDir + " -l 3000 > /tmp/serve.log 2>&1 &");
컨테이너 실측(node:20-alpine, 프로덕션과 동일한 cap-drop/pids/memory 플래그)에서 확인된 사실:
- 패턴이 아무것도 안 잡는다. 실행 중 프로세스의 cmdline은
npm exec serve -s /workspace/app/dist -l 3000과 node /root/.npm/_npx/<hash>/node_modules/.bin/serve -s ...로, 어느 쪽에도 리터럴 npx serve가 없다.
- 대신 자기 자신을 죽인다. 그
pkill을 실행 중인 sh -c 셸의 cmdline에는 패턴 문자열 npx serve가 들어 있어 스스로에게 SIGTERM을 보낸다(exec 종료코드 143).
- 그래서 옛 serve가 살아남는다. 포트 3000이 점유된 상태에서 serve 14는 EADDRINUSE로 죽지 않고 랜덤 포트로 조용히 옮겨 붙으며 성공 로그를 찍는다(실측:
Accepting connections at http://localhost:42547). 게이트웨이는 컨테이너 3000 매핑으로만 프록시하므로(PreviewGatewayService의 hostPort) 낡은 serve = 낡은 dist가 계속 응답한다.
대조군 실측: 패턴을 [s]erve -s로 바꾸면 매치 2건 → 정상 종료(0건).
함께 확인된 인접 결함 (같은 "실패가 조용하다" 계열)
DockerContainerService.exec가 exec 종료코드를 아예 읽지 않는다(inspectExecCmd 미호출, stdout+stderr 문자열만 반환). 따라서 buildIfConfigured의 set -o pipefail도 무의미하고, 빌드 실패가 호출자에게 전달되지 않는다.
startPreviewServer는 serve가 실제로 리스닝 중인지 확인하지 않고 성공으로 반환하며, 그 직후 ProjectPreviewProvisioner가 세션을 ACTIVE로 마킹한다.
- 컨테이너가 재사용되고
prepareProject는 git fetch만 하므로(clean 없음), 이전 성공 빌드의 dist가 남아 있으면 이번 빌드가 실패해도 detectBuildOutputDir이 그 dist를 잡아 낡은 산출물을 성공으로 서빙한다 — PreviewWorkspaceService.java:162-168 javadoc이 명시한 "빌드 실패는 호출자에게 도달해야 한다"는 불변식이 이 경로로 무력화된다.
수정 방향
- pkill 패턴을 실제 cmdline에 맞추고 자기 매치를 피한다(예:
pkill -f "[s]erve -s").
- 기동 후 3000 리스닝을 실제로 확인하고, 아니면 실패로 처리한다(serve 로그의 포트 파싱 또는
netstat/wget 확인).
DockerContainerService.exec에 종료코드 확인 경로를 추가해 빌드/서빙 실패가 조용히 성공으로 넘어가지 않게 한다. 이건 영향 범위가 넓으므로 별도 단위로 분리할 수 있다.
출처
다중 에이전트 조사(2026-08-15, Issue #111 해법 판정 과정)의 컨테이너 실측. 코드 라인은 재확인 완료.
요약
startPreviewServer의pkill -f 'npx serve'가 실제 serve 프로세스를 한 번도 매치하지 못한다. 그 결과 프리뷰를 다시 띄워도 옛 serve가 포트 3000을 계속 쥐고 있어 낡은 빌드 산출물이 서빙된다. 사용자에게는 "고쳤는데 프리뷰가 그대로"로 보인다.근거
PreviewWorkspaceService.java:144-151컨테이너 실측(node:20-alpine, 프로덕션과 동일한 cap-drop/pids/memory 플래그)에서 확인된 사실:
npm exec serve -s /workspace/app/dist -l 3000과node /root/.npm/_npx/<hash>/node_modules/.bin/serve -s ...로, 어느 쪽에도 리터럴npx serve가 없다.pkill을 실행 중인sh -c셸의 cmdline에는 패턴 문자열npx serve가 들어 있어 스스로에게 SIGTERM을 보낸다(exec 종료코드 143).Accepting connections at http://localhost:42547). 게이트웨이는 컨테이너 3000 매핑으로만 프록시하므로(PreviewGatewayService의hostPort) 낡은 serve = 낡은 dist가 계속 응답한다.대조군 실측: 패턴을
[s]erve -s로 바꾸면 매치 2건 → 정상 종료(0건).함께 확인된 인접 결함 (같은 "실패가 조용하다" 계열)
DockerContainerService.exec가 exec 종료코드를 아예 읽지 않는다(inspectExecCmd미호출, stdout+stderr 문자열만 반환). 따라서buildIfConfigured의set -o pipefail도 무의미하고, 빌드 실패가 호출자에게 전달되지 않는다.startPreviewServer는 serve가 실제로 리스닝 중인지 확인하지 않고 성공으로 반환하며, 그 직후ProjectPreviewProvisioner가 세션을 ACTIVE로 마킹한다.prepareProject는git fetch만 하므로(clean 없음), 이전 성공 빌드의 dist가 남아 있으면 이번 빌드가 실패해도detectBuildOutputDir이 그 dist를 잡아 낡은 산출물을 성공으로 서빙한다 —PreviewWorkspaceService.java:162-168javadoc이 명시한 "빌드 실패는 호출자에게 도달해야 한다"는 불변식이 이 경로로 무력화된다.수정 방향
pkill -f "[s]erve -s").netstat/wget확인).DockerContainerService.exec에 종료코드 확인 경로를 추가해 빌드/서빙 실패가 조용히 성공으로 넘어가지 않게 한다. 이건 영향 범위가 넓으므로 별도 단위로 분리할 수 있다.출처
다중 에이전트 조사(2026-08-15, Issue #111 해법 판정 과정)의 컨테이너 실측. 코드 라인은 재확인 완료.