fix(console): 콘솔 없는 실행에서 확인 프롬프트가 무한 대기하던 문제 수정
Symfony 는 posix_isatty 로 비대화 실행을 감지해 isInteractive 를 false 로 내리는데, Windows PHP 에는 posix 확장이 없어 그 분기가 실행되지 않는다. 그래서 --no-interaction 없이 CI·스케줄러·에이전트에서 부르면 isInteractive 가 true 로 남고 ask 가 오지 않을 응답을 무한히 기다린다. 프롬프트 출력마저 버퍼에 갇혀 예외도 로그도 없이 "커맨드가 느리다" 로만 관측된다. posix 없이도 동작하는 stream_isatty 로 한 번 더 판정한다. 판단 재료가 없으면 질문하는 쪽으로 떨어진다 — 물어볼 수 있는데 안 묻는 것이 더 위험하기 때문이다. 이 트레이트를 공유하는 13개 커맨드의 기본값을 전수 확인했다. 파괴적 확인은 모두 false(중단)이고 true 는 search:index --repair 하나뿐이라, 확인 없이 파괴적 동작이 수행되는 경로는 생기지 않는다. 문서는 이미 "비TTY → default 즉시 반환" 을 계약으로 명시하고 있었다 — 구현이 그 계약을 Windows 에서 지키지 못한 것이다.
This commit is contained in:
1 parent
7765695339
commit
b44f2befd7
4 files changed
+104
-6
No files matched your search
@@ -8,6 +8,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 확인 질문이 있는 Artisan 명령(확장 설치·삭제·업데이트, 코어 업데이트, 검색 인덱스 재생성 등)을 터미널이 아닌 곳에서 실행하면 아무 반응 없이 멈춰 있던 문제를 수정했습니다. 예약 작업·배포 스크립트·CI 처럼 사람이 답할 수 없는 환경에서는 질문이 화면에 나오지도 않은 채 응답을 기다렸기 때문에, 명령이 실패한 것도 아니고 진행 중인 것도 아닌 상태로 남아 "명령이 몹시 느리다" 로만 보였습니다. 이제 그런 환경에서는 질문을 건너뛰고 기본 동작을 따릅니다 — 삭제·업데이트처럼 되돌리기 어려운 명령의 기본 동작은 "중단" 이므로, 확인 없이 수행되는 일은 없습니다. 그대로 진행하려면 종전처럼 `--no-interaction` 이나 `--force` 를 붙이면 됩니다.
|
||||
- Windows 에서 확장(모듈·플러그인·템플릿) 업데이트가 "디렉토리 이동 실패" 로 중단되던 문제를 해소했습니다. Windows 는 편집기·개발 도구·파일 감시 프로그램이 폴더를 보고 있기만 해도 폴더 이름 바꾸기를 차단하므로, 폴더를 통째로 이동하는 방식으로는 실패를 피할 수 없었습니다. 이제 이동이 차단되면 파일 단위 교체로 자동 전환해 어떤 프로그램도 종료하지 않고 업데이트를 완료합니다. 잠근 프로그램을 찾아 강제 종료하던 이전 동작은 제거했습니다 — 사용 중인 편집기나 개발 도구가 예고 없이 종료되는 부작용이 있었고, 폴더를 감시만 하는 프로그램은 찾아내지도 못했습니다. 백업 복원에도 같은 방식이 적용되며, 실패한 업데이트가 남긴 임시 폴더는 다음 업데이트 때 자동으로 정리됩니다.
|
||||
|
||||
## [7.0.6] - 2026-08-10
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Console\Commands\Traits;
|
||||
|
||||
use App\Console\Helpers\ConsoleConfirm;
|
||||
use Symfony\Component\Console\Input\StreamableInputInterface;
|
||||
|
||||
/**
|
||||
* Laravel Command 컨텍스트에서 표준화된 yes/no 프롬프트를 제공하는 트레이트.
|
||||
@@ -12,6 +13,7 @@ use App\Console\Helpers\ConsoleConfirm;
|
||||
* 재질문 루프는 ConsoleConfirm::parse() 와 공유한다.
|
||||
*
|
||||
* - `--no-interaction` 시: $default 즉시 반환
|
||||
* - 응답을 받을 수 없는 실행(콘솔 없는 STDIN) 시: $default 즉시 반환
|
||||
* - empty 입력 시: $default 반환
|
||||
* - yes/y → true, no/n → false (대소문자 무시)
|
||||
* - 그 외 입력: "yes, y, no, n 중 하나로 입력해 주세요." 출력 후 재질문
|
||||
@@ -26,7 +28,7 @@ trait HasUnifiedConfirm
|
||||
*/
|
||||
protected function unifiedConfirm(string $question, bool $default = false): bool
|
||||
{
|
||||
if (! $this->input->isInteractive()) {
|
||||
if (! $this->input->isInteractive() || ! $this->canPromptForAnswer()) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
@@ -47,4 +49,38 @@ trait HasUnifiedConfirm
|
||||
$this->warn(' yes, y, no, n 중 하나로 입력해 주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 실행이 프롬프트 응답을 실제로 받을 수 있는지 판정합니다.
|
||||
*
|
||||
* Symfony 는 `posix_isatty()` 로 비대화 실행을 감지해 `isInteractive()` 를 false 로
|
||||
* 내리는데, Windows PHP 에는 posix 확장이 없어 그 분기가 실행되지 않는다. 그래서
|
||||
* `--no-interaction` 없이 CI·스케줄러·에이전트처럼 콘솔이 없는 곳에서 부르면
|
||||
* `isInteractive()` 가 true 로 남고 `ask()` 가 오지 않을 응답을 무한히 기다린다.
|
||||
* 프롬프트 출력마저 버퍼에 갇혀 있어 겉으로는 "커맨드가 느리다" 로만 보인다.
|
||||
*
|
||||
* `stream_isatty()` 는 posix 확장 없이도 Windows 를 포함해 동작하므로 이를 쓴다.
|
||||
* 판정 재료가 없으면 true 를 돌려 기존 동작(질문)을 유지한다 — 물어보지 못해 멈추는
|
||||
* 것보다 물어볼 수 있는데 안 묻는 쪽이 더 위험하기 때문이다.
|
||||
*
|
||||
* @return bool 응답을 받을 수 있으면 true
|
||||
*/
|
||||
protected function canPromptForAnswer(): bool
|
||||
{
|
||||
// 테스트는 QuestionHelper 를 대체하거나 입력을 주입하므로 STDIN 을 쓰지 않는다
|
||||
if ($this->laravel !== null && $this->laravel->runningUnitTests()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 입력 스트림이 주입된 실행(CommandTester::setInputs 등)도 STDIN 과 무관하다
|
||||
if ($this->input instanceof StreamableInputInterface && is_resource($this->input->getStream())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! \defined('STDIN') || ! is_resource(STDIN) || ! \function_exists('stream_isatty')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return stream_isatty(STDIN);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
1. 콘솔 커맨드의 yes/no 프롬프트는 $this->unifiedConfirm() 사용 — Laravel $this->confirm() 직접 호출 금지
|
||||
2. trait: App\Console\Commands\Traits\HasUnifiedConfirm
|
||||
3. 입력 규칙: empty=default, yes/y=true, no/n=false, 그 외=재질문 루프
|
||||
4. --no-interaction 모드: default 즉시 반환
|
||||
4. --no-interaction 또는 콘솔 없는 STDIN: default 즉시 반환 (파괴적 확인의 default 는 false 여야 한다)
|
||||
5. upgrade step / Symfony 외부 환경: \App\Console\Helpers\ConsoleConfirm::ask() FQN 직접 호출
|
||||
```
|
||||
|
||||
@@ -88,10 +88,26 @@ protected function unifiedConfirm(string $question, bool $default = false): bool
|
||||
| 환경 | 동작 |
|
||||
|------|------|
|
||||
| `--no-interaction` 옵션 | `$default` 즉시 반환 |
|
||||
| 콘솔 없는 STDIN (CI·스케줄러·에이전트) | `$default` 즉시 반환 |
|
||||
| 대화형 + 유효 입력 | 정규화 후 즉시 반환 |
|
||||
| 대화형 + 잘못된 입력 | "yes, y, no, n 중 하나로 입력해 주세요." 출력 후 재질문 |
|
||||
| 대화형 + empty 입력 | `$default` 반환 |
|
||||
|
||||
#### 비TTY 판정을 Symfony 에 맡기지 않는 이유
|
||||
|
||||
Symfony 는 `posix_isatty()` 로 비대화 실행을 감지해 `isInteractive()` 를 false 로 내린다.
|
||||
그런데 **Windows PHP 에는 posix 확장이 없어 그 감지 분기 자체가 실행되지 않는다.** 결과적으로
|
||||
`--no-interaction` 없이 콘솔 없는 곳에서 부르면 `isInteractive()` 가 true 로 남고, 프롬프트는
|
||||
버퍼에 갇힌 채 `ask()` 가 오지 않을 응답을 무한히 기다린다. 예외도 로그도 없어 겉으로는
|
||||
**"그 커맨드가 느리다"** 로만 보인다 — 실제로는 영영 끝나지 않는다.
|
||||
|
||||
그래서 `unifiedConfirm()` 은 `canPromptForAnswer()` 로 한 번 더 판정한다. `stream_isatty()` 는
|
||||
posix 확장 없이 Windows 를 포함해 동작하므로 이 판정의 근거로 쓴다. 판정 재료가 없으면
|
||||
`true`(질문함)로 떨어진다 — 물어볼 수 있는데 안 묻는 쪽이 더 위험하기 때문이다.
|
||||
|
||||
이 가드가 의미를 가지려면 **파괴적 확인의 `$default` 는 반드시 `false`(중단)** 여야 한다.
|
||||
콘솔이 없으면 그 기본값이 곧 결정이 되기 때문이다.
|
||||
|
||||
---
|
||||
|
||||
## ConsoleConfirm helper (Symfony 비의존)
|
||||
|
||||
@@ -134,19 +134,64 @@ class HasUnifiedConfirmTest extends TestCase
|
||||
$this->assertStringContainsString('진행할까요? (yes/no) [yes]:', $display);
|
||||
}
|
||||
|
||||
private function makeTester(bool $default): CommandTester
|
||||
/**
|
||||
* 응답을 받을 수 없는 실행에서는 질문하지 않고 기본값을 돌려준다.
|
||||
*
|
||||
* Symfony 는 `posix_isatty()` 로 비대화 실행을 자동 감지하는데 Windows PHP 에는
|
||||
* posix 확장이 없어 그 분기가 실행되지 않는다. 그래서 `--no-interaction` 없이
|
||||
* 에이전트·CI·스케줄러에서 부르면 `isInteractive()` 가 true 로 남아 `ask()` 가
|
||||
* 응답을 기다리며 무한 대기한다. 출력도 남지 않아 "명령이 느리다" 로만 보인다.
|
||||
*/
|
||||
public function test_returns_default_without_asking_when_prompt_cannot_be_answered(): void
|
||||
{
|
||||
$command = new class($default) extends Command
|
||||
$tester = $this->makeTester(true, canPrompt: false);
|
||||
|
||||
$tester->execute([]);
|
||||
|
||||
$display = $tester->getDisplay();
|
||||
$this->assertSame('true', trim($display));
|
||||
$this->assertStringNotContainsString('진행할까요?', $display);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본값이 false 인 파괴적 확인은 응답을 받을 수 없을 때 중단으로 떨어져야 한다.
|
||||
*/
|
||||
public function test_returns_false_default_without_asking_when_prompt_cannot_be_answered(): void
|
||||
{
|
||||
$tester = $this->makeTester(false, canPrompt: false);
|
||||
|
||||
$tester->execute([]);
|
||||
|
||||
$display = $tester->getDisplay();
|
||||
$this->assertSame('false', trim($display));
|
||||
$this->assertStringNotContainsString('진행할까요?', $display);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $default 프롬프트 기본값
|
||||
* @param bool|null $canPrompt 응답 가능 여부 강제 (null 이면 실제 판정 사용)
|
||||
*/
|
||||
private function makeTester(bool $default, ?bool $canPrompt = null): CommandTester
|
||||
{
|
||||
$command = new class($default, $canPrompt) extends Command
|
||||
{
|
||||
use HasUnifiedConfirm;
|
||||
// 트레이트 메서드는 parent:: 로 부를 수 없어 별칭으로 원 구현을 남긴다
|
||||
use HasUnifiedConfirm {
|
||||
canPromptForAnswer as private traitCanPromptForAnswer;
|
||||
}
|
||||
|
||||
protected $signature = 'test:unified-confirm';
|
||||
|
||||
public function __construct(private bool $defaultValue)
|
||||
public function __construct(private bool $defaultValue, private ?bool $canPrompt = null)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function canPromptForAnswer(): bool
|
||||
{
|
||||
return $this->canPrompt ?? $this->traitCanPromptForAnswer();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$result = $this->unifiedConfirm('진행할까요?', $this->defaultValue);
|
||||
|
||||
Reference in new issue
Block a user