Skip to content

fix(window): AX 결과 커밋 정합성 + 실패 경로 노출#103

Merged
pignuante merged 6 commits into
mainfrom
fix/ax-outcome-consistency
Jul 22, 2026
Merged

fix(window): AX 결과 커밋 정합성 + 실패 경로 노출#103
pignuante merged 6 commits into
mainfrom
fix/ax-outcome-consistency

Conversation

@pignuante

@pignuante pignuante commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

알고리즘 감사(.docs/review/algorithm-audit-2026-07-22.md)와 그 검토(.docs/review/algorithm-audit-review-2026-07-23.md) 결과 중 상태 정합성 + 무위험 절감 항목을 처리한다. 사용자 가시 동작 변경은 버그 수정뿐이다.

감사가 제안한 WindowSystem 프로토콜 seam은 ResolvedWindowAXUIElement를 실어 나르는데
scripts/test.sh는 하드코딩된 순수 파일 목록만 swiftc로 컴파일하므로 하네스에서 컴파일되지 않는다.
대신 값 기반 순수 결정 계층(CommandOutcomePolicy)으로 내려 같은 실패 조합을 기존 하네스에서 전수 검증한다.

고친 것

  • 실패한 snap이 성공으로 커밋됐다 — position만 적용되고 size 쓰기가 실패한 부분 frame도 "스냅 완료"로
    기록돼, 다음 같은 방향 입력이 창을 이미 스냅된 것으로 오판해 다른 디스플레이로 던질 수 있었다.
    이제 부분 적용이면 기록 대신 상태를 버린다. (#102의 죽은 SnapStateStore.clear에 호출부가 생긴다)
  • 쓰기는 반영됐는데 최종 read가 실패하면 복원점을 잃었다 — 직전 명령의 undo 항목이 살아남아 Undo 시
    창이 한참 전 frame으로 튀었다. FrameApplyResult.mayHaveMutated로 보수적으로 복원점을 남긴다.
  • 전혀 움직이지 않은 실패(Space 전환 등 transient)에서는 기존 스냅 상태를 유지한다.
  • 정상 경로의 중복 AX 읽기 제거 — 검증용 readFrame 결과를 버리고 최종 frame을 다시 읽던 것을 없애
    명령마다 position/size 읽기 2회가 줄어든다. 재시도 조건 자체는 그대로다.
  • InstallEventHandler OSStatus를 버리던 것 — handler 설치가 실패해도 개별 등록이 성공하면 전체를
    성공으로 보고해 "등록됐지만 이벤트가 안 오는" 유령 단축키가 됐다.
  • AXUIElementSetMessagingTimeout 반환값 무시 — 실패 시 의도한 2초가 아니라 기본 타임아웃으로
    메인 스레드가 막히는데 사후에 원인을 알 수 없었다.
  • 종료된 frontmost 앱 참조 — 죽은 프로세스로 AX 요청을 보낼 수 있던 짧은 구간을 막는다.

범위 밖 (후속)

  • M-1 멀티모니터 이동 공식·종횡비 계약 → 별도 PR (#101과 함께)
  • H-1 AX 액터 이전 → 확정 작업이 아니라 spike. off-main AX 안전성과 화면 매칭 로직의 순수 계층 이전이
    선행 질문이다.

Type

  • fix — bug fix
  • refactor — no behavior change (정책 추출)

Checklist

  • make lint clean (SwiftFormat + SwiftLint --strict) — 0 violations / 49 files
  • make test passes (194 → 202 checks); make coverage 100% (CommandOutcomePolicy.swift 포함)
  • make run(서명 빌드)으로 실행 — 앱 기동·생존 확인
  • 실제 창 조작 수동 검증 완료 — 서명 빌드(make run)로 단축키·스냅·최대화·Undo 동작 확인
  • .docs/ not staged (gitignored)

Notes / risk

수동 검증 결과 (임시 계측 로그 44건):

Executor에 결정 로그를 임시로 넣고 서명 빌드에서 실제 앱을 조작해 판정을 실측했다(계측은 제거됨).

  • 제약 앱 경로 확인 — System Settings(최소 너비 723pt)에 좌측 스냅: target=864x1011인데
    achieved=723x1011. 목표 미달이라 재시도 경로가 실제로 돌았고, 스냅 상태는 목표가 아닌 실제
    frame으로 기록됐다. 이어진 14회 입력은 기하로는 절대 절반과 일치할 수 없어 오직 기록된 상태로
    "이미 스냅됨"을 판정했고 전부 정상 동작했다(이슈 Snap/throw mispositions and won't move windows that can't resize to a half (fixed/min-max/size-increment apps) #37 계열 시나리오).
  • 의도된 no-op (target == pre, 인접 디스플레이 없음) → 스냅 상태 갱신, mayHaveMutated=false
    (AX 쓰기 자체가 안 나감). 이 커밋에서 추가한 분기가 실측으로 확인됐다.
  • 연속 no-op 20회가 복원점을 건드리지 않음 — 감사 H-2가 지적한 "무의미한 frame이 직전 Undo를
    덮어쓴다"가 발생하지 않음을 확인.
  • stale 스냅 기록 자동 무효화 — 창이 100pt로 축소된 뒤에는 이전 스냅 기록과 frame이 달라
    "이미 스냅됨"으로 오판하지 않았다.
  • 디스플레이 간 던지기 양방향, 비스냅 명령(maximize)이 스냅 상태를 건드리지 않는 것도 확인.

관측되지 않은 경로: 무시된 쓰기(target≠pre인데 achieved==pre)와 AX 실패(failed=true).
테스트한 앱들이 모두 정상 동작해 재현되지 않았다 — 단위 테스트로만 덮여 있다.

(검증 도중 ad-hoc make build가 실행 중 서명 빌드의 번들을 덮어써 TCC 권한이 무효화된 적이 있는데,
이는 코드 문제가 아니라 CLAUDE.md에 기록된 DerivedData 공유 함정이었다. 이후 전체 앱 컴파일은
별도 -derivedDataPath로 격리해 수행했다.)

리뷰어가 먼저 볼 곳: make test는 순수 파일 6개만 컴파일한다. 즉 이 PR에서 자동 검증된 것은
CommandOutcomePolicy 하나뿐
이고, Writer/Executor/Resolver/HotkeyService 배선은 타입 체크만 거쳤다.
남은 수동 검증 항목:

  • 단축키가 애초에 발동하는가 (installEventHandlerIfNeeded가 void → Bool로 바뀌었다. 여기가 깨지면 모든 단축키가 조용히 죽는다)
  • 일반 리사이즈 앱(Safari/TextEdit): 좌우 스냅 · 최대화 · Undo — 성공 경로 단일 read가 올바른 frame을 돌려주는지
  • 제약 앱(Terminal, 크기 증분): 스냅 시 재깜빡임이 없는지, 같은 방향 두 번째 입력이 정상 동작하는지
  • 모니터 2대: 인접 디스플레이 던지기

GUI 검증은 CGWindowListCopyWindowInfo/kCGWindowName을 쓰지 않는다(Screen Recording TCC 게이트 —
WindowServer 워치독 전례).

…mits

부분 적용·최종 read 실패 같은 위험한 조합은 AX 없이는 재현할 수 없어 자동 검증 밖에 있었다.
"이 결과에서 Undo/Snap 상태를 어떻게 커밋할 것인가"만 값으로 판단하는 순수 계층으로 내려,
기존 swiftc 하네스에서 전수 검증할 수 있게 한다.

AXUIElement를 실어 나르는 프로토콜 seam 대신 값 기반 결정 계층을 택했다 — test.sh는 하드코딩된
순수 파일 목록만 컴파일하므로 AX 타입이 섞이면 하네스에서 컴파일되지 않는다.

새 순수 파일이라 test.sh·coverage.sh 소스 목록에 함께 추가한다.
…ites

세 가지 상태 정합성 문제를 고친다.

- 실패한 snap이 성공으로 커밋됐다: position만 적용되고 size 쓰기가 실패한 부분 frame도
  "스냅 완료"로 기록돼, 다음 같은 방향 입력이 창을 이미 스냅된 것으로 오판해 다른
  디스플레이로 던질 수 있었다. 이제 부분 적용이면 기록 대신 상태를 버린다.
- 쓰기는 반영됐는데 최종 read가 실패하면 복원점을 잃었다: 직전 명령의 undo 항목이 살아남아
  Undo 시 창이 한참 전 frame으로 튀었다. FrameApplyResult에 mayHaveMutated를 실어
  보수적으로 복원점을 남긴다.
- 전혀 움직이지 않은 실패(Space 전환 등 transient)에서는 기존 스냅 상태를 유지한다.

성공 경로에서 검증용 readFrame 결과를 버리고 최종 frame을 다시 읽던 것도 없앤다 —
정상 명령마다 position/size AX 읽기 2회가 줄어든다. 재시도를 실제로 한 경우와 검증 읽기가
실패한 경우에만 다시 읽는다(기존 재시도 조건은 그대로 유지).
- InstallEventHandler의 OSStatus를 버리고 있었다. handler 설치가 실패해도 개별 hotkey
  등록이 성공하면 전체를 성공으로 보고해, 단축키는 등록됐지만 이벤트가 오지 않는 유령
  상태가 됐다. 이제 설치 실패 시 등록을 시작하지 않고 실패로 보고한다.
- AXUIElementSetMessagingTimeout의 반환값을 확인한다. 설정이 실패하면 이후 AX 호출이
  의도한 2초가 아니라 기본 타임아웃으로 메인 스레드를 막는데, 조용히 넘기면 사후에
  원인을 알 수 없었다.
- 종료된 frontmost 앱을 잠시 계속 참조하던 경로를 막는다 — 다른 앱의 activation
  notification이 도착하기 전 구간에 죽은 프로세스로 AX 요청을 보낼 수 있었다.
이번 작업에서 실제로 시간을 잃은 다섯 가지를 CLAUDE.md에 남긴다.

- 실행 중인 서명 빌드가 있는 상태에서 `make build`를 돌리면 번들이 교체되며 서명이 무효화돼
  macOS가 Accessibility 권한을 조용히 회수한다. System Settings에는 체크가 남아 있어 "권한은
  있는데 아무것도 안 되는" 상태가 된다. 기존 항목은 이 방향의 실패를 다루지 않았다.
- `HotkeyService` 변경은 반드시 실제 단축키로 검증해야 한다 — 상태바 메뉴는 이 경로를 거치지
  않아 통과해도 아무것도 증명하지 못한다. 동시에 가장 빠른 진단 축이기도 하다.
- GUI 스모크의 "AX role 확인"은 에이전트가 실행할 수 없다. 조회하는 쪽 프로세스가 권한을
  가져야 해서, ad-hoc 스크래치 바이너리는 -25211(apiDisabled)을 받는다.
- SwiftLint --strict의 cyclomatic complexity 상한(10)을 명시한다.
- test.sh/coverage.sh의 하드코딩 목록이 자동 검증되는 코드의 전부라는 결론을 명시하고, AX 실패
  경로를 덮는 방법(순수 결정 함수 추출)을 기록한다.
… write

AX가 .success를 돌려줬다는 건 호출을 받아들였다는 뜻이지 창이 목표에 도달했다는 보증이 아니다.
목표가 현재 frame과 달랐는데도 창이 그대로면 앱이 쓰기를 무시한 것인데, 정책이 이걸 "인접
디스플레이가 없어 의도적으로 no-op인 경우"와 구분하지 못해 스냅된 적 없는 창을 스냅됨으로
기록했다. 그러면 다음 같은 방향 입력에서 isAlreadySnapped가 기록된 frame과 현재 frame이
일치하는 걸 보고 창을 인접 디스플레이로 던져버린다.

결과(achieved == pre)만으로는 두 경우를 구분할 수 없으므로 CommandOutcome이 의도(target)를
값으로 받는다. target == pre면 의도된 no-op이라 스냅 상태를 갱신하고, target이 달랐는데
창이 안 움직였으면 아무것도 커밋하지 않는다.

기존 테스트가 두 종류의 no-op을 같은 입력으로 뭉개고 있어 이 정보 손실을 못 잡았다.
테스트 입력을 Outcome 구조체로 바꿔 target을 명시하고, 무시된 쓰기 케이스를 추가한다.
… binding

handler 설치가 실패하면 register가 바인딩마다 재시도해, 34개 바인딩에서 34번의
InstallEventHandler 호출과 동일한 오류 로그 34줄이 나왔다. reload에서 한 번만 시도하고
실패 시 전부 실패로 반환한다. register 자체의 가드는 직접 호출자를 위해 남긴다.

사용자에게 보이는 결과(모든 행에 배지)는 같다 — 서비스 수준 실패와 개별 충돌을 다른 문구로
구분하는 것은 별도 과제로 남긴다.
@pignuante pignuante self-assigned this Jul 22, 2026
@pignuante
pignuante merged commit 31b85bf into main Jul 22, 2026
3 checks passed
@pignuante
pignuante deleted the fix/ax-outcome-consistency branch July 22, 2026 17:38
pignuante added a commit that referenced this pull request Jul 23, 2026
* fix(window): abort resolution when AX messaging-timeout config fails

setMessagingTimeout 실패를 로그만 남기고 기본 6초 경로로 계속 진행하던 것을, 즉시
해석 중단으로 바꾼다. 타임아웃 값은 상수 2.0이라 SDK가 실패를 반환하는 경우는 무효
element뿐인데, 그 element로는 이후 AX 읽기·쓰기도 성공할 근거가 없다.

WindowResolutionError에 messagingTimeoutConfigurationFailed(code:)를 추가하고
userFacingMessage를 붙였다. 두 호출부(app·window element)가 실패를 전파한다.

Shared·WindowAccess AGENTS.md에 이 동작을 반영했다.

* docs(agents): bring AGENTS.md current with CommandOutcomePolicy and test split

이미 머지된 변경을 per-directory 문서에 반영한다.
- CommandOutcomePolicy(순수 상태 커밋 정책, #103)를 데이터 흐름과 Commands 목록에 추가
- 테스트 파일이 도메인별 CommandEngineTests*.swift 5개로 분할된 것(#106)을 반영

* docs: record isolated-build and stacked-PR gotchas in CLAUDE.md

이번 세션에서 겪은 두 함정을 남긴다.

- 실행 중인 서명 앱을 건드리지 않고 컴파일 검증하려면 별도 -derivedDataPath로 빌드한다.
  #if DEBUG는 구성별로 다르므로 그 블록을 건드릴 땐 Debug·Release 양쪽을 확인한다.
- squash-merge가 head 브랜치를 지우므로 그 위에 스택된 PR은 머지 시 자동으로 닫히고
  재개설·base 변경이 불가하다 — main 대상으로 재개설한다. 한 PR이 다른 PR이 수정하는
  코드를 옮기면(테스트 파일 분할 등) 내용 변경을 먼저, 이동을 마지막에 머지한다.
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