Skip to content

fix(windows): keep ACL stalls off the response event loop - #3018

Open
Ingwannu wants to merge 1 commit into
devfrom
ingw/fix-3011-async-acl-spill
Open

fix(windows): keep ACL stalls off the response event loop#3018
Ingwannu wants to merge 1 commit into
devfrom
ingw/fix-3011-async-acl-spill

Conversation

@Ingwannu

@Ingwannu Ingwannu commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Summary

  • move optional Windows config-directory ACL hardening to a per-directory async single-flight
  • add an async, fail-closed response-spill publisher for Windows while preserving the existing Linux/macOS synchronous path
  • serialize Windows spill publication, retain the resident continuation until compare-before-swap succeeds, and discard stale published generations
  • keep pending spill memory bounded at 256 MiB and account pending/detached payloads as pinned app-owned memory
  • retry one real ACL timeout before installing a spill-failed tombstone
  • document the cross-platform and crash-ordering decision

Fixes #3011.

Why

The remaining runtime spill/config paths called Bun.spawnSync(icacls) on the same Bun event loop that serves /healthz. A transient Windows ACL stall could therefore stop every request for the full timeout and immediately replace a replayable continuation with a process-lifetime tombstone.

This change does not weaken required ACL checks and does not increase the timeout. Windows waits on the existing async ACL runner; publication happens only after temp-file hardening, fsync, no-replace publication, and a final resident-generation identity check.

Validation

  • ./node_modules/.bin/bun scripts/test.ts --parallel=2 tests/responses-state.test.ts tests/config.test.ts tests/windows-secret-acl.test.ts tests/app-owned-memory.test.ts — 469 pass, 0 fail
  • bun run typecheck — pass
  • bun run privacy:scan — pass
  • deterministic regressions cover an ACL promise that remains pending while unrelated event-loop work runs, one bounded timeout recovery, and a same-id replacement that cannot be overwritten by an older publication

The real icacls subprocess path still needs the repository Windows CI jobs before merge.

Review notes

This touches Windows secret ACL and continuation durability boundaries, so it intentionally does not self-approve or self-merge. Please verify the exact head and Windows CI before applying security sponsorship.

@Ingwannu
Ingwannu requested a review from lidge-jun as a code owner August 30, 2026 16:12
@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@Ingwannu

Copy link
Copy Markdown
Owner Author

CI note for exact head 400a6a3849d4cbf3a5f95f9f86e55bfe4ad4e831: test 2/4 failed only at tests/release-version-line.test.ts:108 (Expected: true, Received: false). This is the already-confirmed dev baseline defect: dev still carries published version 2.36.0, which #3006/#3013 are competing to repair.

The other completed gates, including typecheck, privacy, API usage, storage policy, keyring, npm-global, and shards 1/3/4, are green. I am not rerunning or waiving the red. #3018 remains blocked until the version-line PR is resolved, this branch is rebased onto the repaired dev, exact-head CI is rerun, and the requested owner/security review is complete.

@lidge-jun

Copy link
Copy Markdown
Owner

리뷰 · 우선순위 68 / 80

이 PR은 이슈 #3011 을 고친다. 지금 dev HEAD 1031b6fff 에서도 구멍은 그대로다. 윈도우에서 프록시 포트는 LISTEN 인데 /healthz 가 수십 초 동안 답이 없다. 프로세스는 안 죽고 나중에 혼자 살아난다. #3011 작성자가 분 단위로 2125번 찔러 본 숫자다. 실패한 탐침 6번, 제일 긴 지연 47139ms. 그중 40956ms 짜리는 서비스 로그의 ACL 타임아웃과 초가 같다. /healthz 는 로컬 JSON만 만든다. 포트가 열려 있는데도 응답이 멈춘다는 것은, 다른 일이 이벤트 루프를 붙잡고 있다는 뜻이다.

그 다른 일이 동기 ACL 이다. #612src/lib/windows-secret-acl.tshardenSecretPathAsync / hardenSecretDirAsync 를 넣었다. src/config/atomic-write.tssrc/codex/native-profile-manager.ts 는 그 비동기 API 를 쓴다. 그런데 런타임에 남는 동기 길이 두 개가 이슈가 적은 그대로다. src/config/paths.tshardenConfigDir()hardenSecretDir(dir, { required: false })Bun.spawnSync 로 기다린다. src/responses/spill-store.tswriteResponseSpillDurably() 는 스필 디렉터리와 임시 파일을 required: true 로 동기로 굳힌다. src/responses/state.ts 는 그 쓰기를 rememberResponseState 강등, 큰 후보 직접 입학, pruneResponses, evictOldestResponseContinuationForBudget 네 곳에서 부른다. 기본 예산은 30000ms, 환경 변수로 최대 60000ms 다. 타임아웃이 나도 그 시간 동안 /healthz 는 선다.

스필 쪽은 헬스보다 더 세다. required: true 타임아웃은 예외가 된다. 지금의 동기 작성자는 그걸 Response spill write failed 로 다시 던진다. state.ts 는 그 예외를 spillWriteFailures 로 세고 replaceWithSpillFailure 로 톰스톤을 넣는다. #3011 이 재시작 전에 본 숫자, 스필 쓰기 849번에 실패 2032번, 톰스톤 46개가 그 길이다. 톰스톤은 previous_response_id 의 제공자 상태를 안 돌려준다. 그래서 헬스만 잠깐 아픈 것이 아니라 이어가기가 그 프로세스 안에서 죽는다. #2963 은 이미 맞는 ACL 이면 변이를 건너뛴다. 부를 때는 아직 동기 대기다. 타임아웃을 늘리는 고침이 아니다. types.ts/config.ts 분할과도 안 겹친다. 미리보기 배포도 없다. 닫을 중복도 아니다.

이 PR이 하는 일은 그 남은 런타임 길이를 윈도우만 비동기로 옮기는 것이다. 리눅스와 맥의 즉시 쓰기, 크래시 순서, 실패 닫힘은 그대로 둔다. hardenConfigDir 는 디렉터리마다 선택 ACL 을 한 번만 띄운다. 기다리지 않는다. 실패해도 삼킨다. 스필은 writeResponseSpillDurablyAsynchardenSecretDirAsync / hardenSecretPathAsync 를 기다린다. 임시 파일 쓰기, fsync, 덮어쓰지 않는 발행, 마지막에 메모리 세대가 같은지 다시 보는 비교는 동기 작성과 같다. state.ts 는 윈도우에서 그 발행을 한 줄 큐로 세우고, ACL 이 끝나기 전에는 원래 이어가기를 메모리에 남겨 둔다. 같은 아이디의 새 값이 먼저 오면 늦은 발행은 파일을 지우고 새 값을 덮지 않는다. 대기 바이트는 256 MiB 로 막는다. 핀으로 세고, 512 MiB 프로세스 천장 안에 넣는다. 진짜 ETIMEDOUT 한 번은 retryTimedOutOnce 로 다시 시도하고, 그때는 톰스톤을 안 넣는다.

리눅스에서 증명한 세 가지가 이슈가 원하던 테스트다. 느린 ACL 약속이 아직 안 끝났을 때 다른 setTimeout 이 돈다. 한 번 타임아웃이 나도 톰스톤이 안 생긴다. 같은 아이디의 새 값이 늦은 발행에 안 덮인다. 작성자 로컬은 tests/responses-state.test.ts tests/config.test.ts tests/windows-secret-acl.test.ts tests/app-owned-memory.test.ts 469개 통과, typecheck 통과, privacy scan 통과라고 적었다. 헤드 400a6a384 의 리눅스 CI 에서 test 1/4 3/4 4/4 는 초록이다. test 2/4 빨강은 tests/release-version-line.test.ts 라인 108, 이미 공개된 버전 줄을 이 커밋이 가리키지 않는다는 검사다. 스냅샷이 #3006 / #3013 잔여로 적어 둔 그것이다. 이 PR이 버전을 만지지 않는다. 윈도우 본문 샤드는 스킵이다. 진짜 icacls 경로는 아직 CI 가 안 돌렸다. 작성자가 머지 전에 그 길이 필요하다고 적은 그대로다.

src/config/paths.ts 라인 38 - 선택 설정 디렉터리 경화는 기다리지 않고 한 번만 띄운다. 필수 쓰기 길은 그 비행에 안 붙는다. 같은 디렉터리에 icacls 가 겹칠 수 있다. #596 은 상속을 먼저 끊고 소유자 ACE 가 없으면 빈 DACL 이 남는 구멍이다. 예전에도 필수 비동기 쓰기가 양보하는 동안 동기 hardenConfigDir 가 끼어들 수 있었다. 지금은 요청이 더 살아 있어서 겹칠 기회가 늘어난다.
src/responses/state.ts 라인 575 - 윈도우에서 큰 후보는 스필이 끝나기 전까지 메모리 거주자로 남는다. 바로 위 주석은 큰 후보가 거주자가 되지 않는다고 적는다. 주석과 코드가 다르다. pruneResponses 는 대기 중인 거주자를 건너뛰므로, 64 MiB 거주 한도를 넘는 값이 ACL 이 끝날 때까지 그 한도 위에 앉아 있다.
src/responses/state.ts 라인 286 - 대기 바이트가 256 MiB 를 넘으면 그 후보는 바로 톰스톤이다. 거주 한도 64 MiB 안의 보통 강등만으로는 잘 안 닿는다. 큰 후보 직접 입학이 ACL 지연 동안 여러 개 쌓이면 #3011 이 피하려던 그 톰스톤이 다시 생긴다. 이 한도를 증명하는 테스트는 없다.
src/responses/state.ts 라인 1362 - 윈도우 예산 퇴출은 큐만 넣고 0 을 돌려준다. enforceAppOwnedMemoryBudget 는 0 이면 그 저장소를 이번 라운드에서 뺀다. 무한 루프는 아니다. 다만 ACL 이 끝날 때까지 이어가기 바이트는 핀으로 남고, 퇴출 가능 후보가 없다고 경고할 수 있다.
tests/responses-state.test.ts 라인 173 - afterEachflushPendingResponseSpillsForTests 를 기다리지 않는다. 새 테스트 세 개는 각자 flush 한다. 중간에 깨진 테스트가 남긴 발행이 다음 테스트의 clearResponseStateForTests 뒤에 끝날 수 있다.
CI windows shards - 헤드에서 윈도우 본문 샤드가 스킵이다. 리눅스 주입 실행기는 이벤트 루프와 큐 순서를 증명한다. 실제 icacls 타임아웃과 NTFS 발행은 아직 이 커밋에서 안 돌았다.

메인테이너의 판단이 필요한 지점

  • 윈도우 샤드가 이번 헤드에서 실제로 돈 뒤에 머지할지, 리눅스 회귀만 보고 보안 스폰서십을 먼저 줄지. 작성자는 윈도우 CI 를 머지 전 조건으로 적었다.
  • 선택 hardenConfigDir 비행을 필수 쓰기와 같은 디렉터리 단일 비행에 붙일지. 지금 구조는 헬스 생존이 먼저고, 겹치는 icacls 는 남는 값이다.
  • 대기 256 MiB 를 넘긴 큰 후보를 톰스톤 대신 거주자로 더 붙들고 새 입학만 거절할지. 지금 코드는 한도를 넘으면 바로 spill-failed 다.

너의 추천
닫지 않는다. 이어서 본다. 방향은 #3011 리뷰가 적던 그대로다. 타임아웃을 늘리지 않았고, 필수 ACL 실패 닫힘도 약하게 만들지 않았다. 리눅스 세 회귀는 이슈가 원하던 증명이다. test 2/4 버전 줄은 이 PR 죄가 아니니 여기 붙이지 않는다. 윈도우 샤드가 이 헤드 400a6a384 에서 실제로 초록이 된 뒤에 머지한다. 그 전에 보안 승인만 먼저 누르지 않는다. 가능하면 afterEach 에 flush 를 넣고, 큰 후보 윈도우 경로 주석을 코드와 같게 고친다.

이 댓글은 grok-bot이 작성했습니다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants