From 51ee9b4219637a79d48fa71f80e5c1de4bb8fea5 Mon Sep 17 00:00:00 2001 From: beyondworks Date: Thu, 6 Aug 2026 17:55:18 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore(repro):=20=EC=9C=88=EB=8F=84=EC=9A=B0?= =?UTF-8?q?=20rename=20=EA=B2=BD=ED=95=A9=20=EC=9E=84=EC=8B=9C=20=EC=9E=AC?= =?UTF-8?q?=ED=98=84=20(=EC=A1=B0=EC=82=AC=20=ED=9B=84=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .github/workflows/repro-tmp.yml | 15 +++++++++ repro-rename-race.mjs | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 .github/workflows/repro-tmp.yml create mode 100644 repro-rename-race.mjs diff --git a/.github/workflows/repro-tmp.yml b/.github/workflows/repro-tmp.yml new file mode 100644 index 00000000..581232b0 --- /dev/null +++ b/.github/workflows/repro-tmp.yml @@ -0,0 +1,15 @@ +# 임시 — crewmail rename 경합 윈도우 재현 (조사 후 삭제) +name: repro-tmp +on: workflow_dispatch +permissions: + contents: read +jobs: + repro: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: node repro-rename-race.mjs diff --git a/repro-rename-race.mjs b/repro-rename-race.mjs new file mode 100644 index 00000000..a033fa55 --- /dev/null +++ b/repro-rename-race.mjs @@ -0,0 +1,55 @@ +// 임시 재현 스크립트 — 윈도우 CI에서 crewmail 선점 rename의 상호배제가 깨지는지 실측. +// (조사용 — PR에 포함하지 않는다) +import { mkdtemp, rm, writeFile, rename, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const N = Number(process.env.REPRO_N ?? 2000); + +// ── 실험 1: 프리미티브 — 같은 소스를 두 주체가 동시에 rename ── +async function primitive() { + const dir = await mkdtemp(join(tmpdir(), 'repro-prim-')); + const dist = { 0: 0, 1: 0, 2: 0 }; + const codes = {}; + for (let i = 0; i < N; i++) { + const src = join(dir, `m${i}.json`); + const dst = `${src}.claimed`; + await writeFile(src, '{"x":1}'); + const rs = await Promise.allSettled([rename(src, dst), rename(src, dst)]); + const ok = rs.filter((r) => r.status === 'fulfilled').length; + dist[ok] += 1; + for (const r of rs) if (r.status === 'rejected') codes[r.reason.code] = (codes[r.reason.code] ?? 0) + 1; + if (ok === 2) console.log(`[prim] i=${i} 두 rename 모두 성공!`); + await rm(dst, { force: true }); await rm(src, { force: true }); + } + console.log('[prim] dist:', dist, 'reject codes:', codes); + await rm(dir, { recursive: true, force: true }); + return dist[2]; +} + +// ── 실험 2: 실제 경로 — deliverCrewMail 두 개 동시 (테스트와 동일, 단 sleep 0) ── +async function realpath() { + process.env.ARGO_ROOT = await mkdtemp(join(tmpdir(), 'repro-real-')); + const { paths } = await import('./src/workspace.mjs'); + const mod = await import('./src/crewmail.mjs'); + const WS = 'repro-ws'; + await mkdir(paths(WS).root, { recursive: true }); + await writeFile(paths(WS).company, JSON.stringify({ id: WS, name: 'r' })); + let doubles = 0; + for (let i = 0; i < Math.min(N, 500); i++) { + await mod.sendCrewMail(WS, { from: 'a', fromName: 'A', to: 'race', message: `r${i}` }); + let calls = 0; + const run = () => mod.deliverCrewMail(WS, async () => { calls += 1; await new Promise((r) => setTimeout(r, 5)); }); + await Promise.all([run(), run()]); + if (calls !== 1) { doubles += 1; console.log(`[real] i=${i} calls=${calls}`); } + // 잔재 청소 — 다음 회차 오염 방지 + await rm(join(paths(WS).root, 'mail'), { recursive: true, force: true }); + } + console.log(`[real] doubles=${doubles}/${Math.min(N, 500)}`); + await rm(process.env.ARGO_ROOT, { recursive: true, force: true }); + return doubles; +} + +const p = await primitive(); +const r = await realpath(); +console.log(`RESULT primitive-double=${p} realpath-double=${r}`); From aeed491a63d1afb41891b8b4a4bfb4a4557c2ef1 Mon Sep 17 00:00:00 2001 From: beyondworks Date: Thu, 6 Aug 2026 17:56:05 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore(repro):=20win-debug=20=EC=8A=AC?= =?UTF-8?q?=EB=A1=AF=20=EC=9E=AC=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .github/workflows/{repro-tmp.yml => win-debug.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{repro-tmp.yml => win-debug.yml} (100%) diff --git a/.github/workflows/repro-tmp.yml b/.github/workflows/win-debug.yml similarity index 100% rename from .github/workflows/repro-tmp.yml rename to .github/workflows/win-debug.yml From 615f1c50123810687615b340065cce620d2f97a3 Mon Sep 17 00:00:00 2001 From: beyondworks Date: Thu, 6 Aug 2026 18:03:43 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(crewmail):=20=EC=AA=BD=EC=A7=80=20?= =?UTF-8?q?=EC=84=A0=EC=A0=90=EC=9D=84=20mkdir=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=E2=80=94=20=EC=9C=88=EB=8F=84=EC=9A=B0=20?= =?UTF-8?q?rename=20=EB=8F=99=EC=8B=9C=20=EC=84=B1=EA=B3=B5(=EC=9D=B4?= =?UTF-8?q?=EC=A4=91=20=EB=B0=B0=EB=8B=AC)=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .github/workflows/win-debug.yml | 23 +++++++++++++++++++---- repro-mutate.mjs | 15 +++++++++++++++ src/crewmail.mjs | 21 +++++++++++++++++---- test/crewmail.test.mjs | 19 ++++++++++++------- 4 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 repro-mutate.mjs diff --git a/.github/workflows/win-debug.yml b/.github/workflows/win-debug.yml index 581232b0..0f9c11cf 100644 --- a/.github/workflows/win-debug.yml +++ b/.github/workflows/win-debug.yml @@ -1,15 +1,30 @@ -# 임시 — crewmail rename 경합 윈도우 재현 (조사 후 삭제) -name: repro-tmp +# 임시 — crewmail rename 경합 윈도우 재현·수정 검증 (조사 후 삭제) +name: win-debug on: workflow_dispatch permissions: contents: read jobs: repro: runs-on: windows-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - - run: node repro-rename-race.mjs + cache: npm + - run: npm ci + - name: repro (수정 후 — realpath-double=0 기대) + run: node repro-rename-race.mjs + - name: full tests + run: npm test + - name: mutation probe (게이트 제거 → 경합 테스트 red 기대) + shell: bash + run: | + node repro-mutate.mjs + if node --test test/crewmail.test.mjs; then + echo "MUTANT SURVIVED — 테스트가 결함을 못 잡는다"; exit 1 + else + echo "mutant killed — 강화된 테스트가 rename 단독 선점을 red로 잡음" + fi + git checkout -- src/crewmail.mjs diff --git a/repro-mutate.mjs b/repro-mutate.mjs new file mode 100644 index 00000000..31210f1a --- /dev/null +++ b/repro-mutate.mjs @@ -0,0 +1,15 @@ +// 임시 변이 스크립트 — mkdir 게이트를 제거하고 옛 rename 단독 선점으로 되돌린다(결함 하나만). +// 강화된 선점 경합 테스트가 이 변이를 윈도우에서 red로 잡는지 확인하는 프로브(조사 후 삭제). +import { readFileSync, writeFileSync } from 'node:fs'; + +const f = 'src/crewmail.mjs'; +const src = readFileSync(f, 'utf8'); +const start = src.indexOf(' const gate = '); +const endMarker = ' await dropGate();\n'; +const end = src.indexOf(endMarker, start); +if (start < 0 || end < 0) { console.error('변이 앵커를 못 찾음'); process.exit(2); } +const mutated = src.slice(0, start) + + " const claimedPath = `${item.full}.claimed`;\n try { await rename(item.full, claimedPath); } catch { continue; }\n" + + src.slice(end + endMarker.length); +writeFileSync(f, mutated); +console.log('mutated: mkdir 게이트 제거 → rename 단독 선점(출하본과 동형)'); diff --git a/src/crewmail.mjs b/src/crewmail.mjs index 4078dff6..c57dbd69 100644 --- a/src/crewmail.mjs +++ b/src/crewmail.mjs @@ -113,8 +113,9 @@ export function mailPrompt(msg, lang = 'ko', { hasTools = true } = {}) { /** 우편 배달 — 스케줄러 틱에서 호출. runTurn(slug, msg, { from, hop, chain })을 주입받는다 (chat.mjs 직접 import는 순환이 되고, 주입이라야 단위 테스트가 배선까지 태울 수 있다). - 선점은 **rename 단독** — 원자적이라 승자가 1명이다(분리 검수 CRITICAL-1: writeFile 선행 방식은 - rename으로 집힌 원본을 되살려 이중 배달을 만들었다 — 21회 중 3회 실측). 반환: 이번 틱 처리 수. */ + 선점은 **mkdir 게이트 + rename** — 승자가 1명이다(분리 검수 CRITICAL-1: writeFile 선행 방식은 + rename으로 집힌 원본을 되살려 이중 배달을 만들었다 — 21회 중 3회 실측. rename 단독도 윈도우에선 + 상호배제가 아니다 — 아래 선점 주석의 2026-08-06 실측). 반환: 이번 틱 처리 수. */ export async function deliverCrewMail(wsId, runTurn, { limit = MAIL_PER_TICK, now = Date.now() } = {}) { const all = await pendingBySlug(wsId); let done = 0; @@ -134,9 +135,21 @@ export async function deliverCrewMail(wsId, runTurn, { limit = MAIL_PER_TICK, no } continue; } - // 선점 — rename 원자성만 신뢰(승자 1명). 패자는 ENOENT로 continue. + // 선점 — rename 단독으로는 부족하다: 윈도우 MoveFileEx는 내부가 "소스 핸들 열기 → 핸들 rename"이라 + // 두 주체의 호출이 겹치면 둘 다 rename 전에 핸들을 열어 **둘 다 성공**한다(2026-08-06 windows-latest + // 실측: 동시 rename 2000회 중 1999회 이중 성공 — POSIX는 커널이 이름 기준 원자 처리라 패자 ENOENT). + // mkdir은 양 플랫폼 모두 이름 기준 원자(패자 EEXIST)이므로 게이트로 승자를 1명으로 좁힌 뒤 rename한다 + // — 게이트가 rename 호출의 동시 실행 자체를 막아 핸들 경합 창이 열리지 않는다. + const gate = `${item.full}.gate`; + const dropGate = () => rm(gate, { recursive: true, force: true }).catch(() => {}); + try { await mkdir(gate); } catch { + // 크래시 잔재 게이트 — 방치되면 이 메시지가 영영 잠긴다. 오래된 것만 회수(claim은 다음 틱에). + try { if (now - (await stat(gate)).mtimeMs > CLAIM_STALE_MS) await dropGate(); } catch { /* 경합 소거 등 */ } + continue; + } const claimedPath = `${item.full}.claimed`; - try { await rename(item.full, claimedPath); } catch { continue; } + try { await rename(item.full, claimedPath); } catch { await dropGate(); continue; } + await dropGate(); inFlight.add(claimedPath); done += 1; let msg = null; diff --git a/test/crewmail.test.mjs b/test/crewmail.test.mjs index 98a80b82..90953a70 100644 --- a/test/crewmail.test.mjs +++ b/test/crewmail.test.mjs @@ -65,13 +65,18 @@ test('mailPrompt hasTools:false — CLI 러너 수신 턴에 send_to_crew 지시 } }); -test('선점 경합 — 두 배달 주체가 동시에 돌아도 배달은 1회(rename 원자성, 검수 CRITICAL-1)', async () => { - await mod.sendCrewMail(WS, { from: 'a', fromName: '알파', to: 'race', message: '경합 검증' }); - let calls = 0; - const run = () => mod.deliverCrewMail(WS, async () => { calls += 1; await new Promise((r) => setTimeout(r, 40)); }); - await Promise.all([run(), run()]); - assert.equal(calls, 1, '이중 배달 — 선점이 상호배제가 아니다'); - assert.deepEqual(await mailFiles('race'), []); +test('선점 경합 — 두 배달 주체가 동시에 돌아도 배달은 1회(mkdir 게이트, 검수 CRITICAL-1)', async () => { + // rename 단독 선점은 윈도우에서 상호배제가 아니다 — MoveFileEx 내부 "핸들 열기 → 핸들 rename" + // TOCTOU로 겹친 두 rename이 둘 다 성공한다(2026-08-06 windows-latest 실측: 회차당 ~97% 이중 배달, + // run 31085690668 간헐 실패의 뿌리). 단발이면 겹침이 안 난 회차가 통과해 플레이크가 되므로 반복한다. + for (let i = 0; i < 30; i++) { + await mod.sendCrewMail(WS, { from: 'a', fromName: '알파', to: 'race', message: `경합 검증 ${i}` }); + let calls = 0; + const run = () => mod.deliverCrewMail(WS, async () => { calls += 1; await new Promise((r) => setTimeout(r, 5)); }); + await Promise.all([run(), run()]); + assert.equal(calls, 1, `i=${i} 이중 배달 — 선점이 상호배제가 아니다`); + assert.deepEqual(await mailFiles('race'), [], `i=${i} 선점 잔재(게이트·claimed)가 남았다`); + } }); test('cc 상한 — CC_MAX 초과는 통째로 거절(팬아웃 총량 방어, 검수 MEDIUM)', async () => { From b8de17e8c82a39380e6ed82b99667a7ad5f80cae Mon Sep 17 00:00:00 2001 From: beyondworks Date: Thu, 6 Aug 2026 18:06:15 +0900 Subject: [PATCH 4/4] =?UTF-8?q?chore(repro):=20=EC=A1=B0=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EC=9E=84=EC=8B=9C=20=EC=9E=AC=ED=98=84=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .github/workflows/win-debug.yml | 30 ------------------ repro-mutate.mjs | 15 --------- repro-rename-race.mjs | 55 --------------------------------- 3 files changed, 100 deletions(-) delete mode 100644 .github/workflows/win-debug.yml delete mode 100644 repro-mutate.mjs delete mode 100644 repro-rename-race.mjs diff --git a/.github/workflows/win-debug.yml b/.github/workflows/win-debug.yml deleted file mode 100644 index 0f9c11cf..00000000 --- a/.github/workflows/win-debug.yml +++ /dev/null @@ -1,30 +0,0 @@ -# 임시 — crewmail rename 경합 윈도우 재현·수정 검증 (조사 후 삭제) -name: win-debug -on: workflow_dispatch -permissions: - contents: read -jobs: - repro: - runs-on: windows-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci - - name: repro (수정 후 — realpath-double=0 기대) - run: node repro-rename-race.mjs - - name: full tests - run: npm test - - name: mutation probe (게이트 제거 → 경합 테스트 red 기대) - shell: bash - run: | - node repro-mutate.mjs - if node --test test/crewmail.test.mjs; then - echo "MUTANT SURVIVED — 테스트가 결함을 못 잡는다"; exit 1 - else - echo "mutant killed — 강화된 테스트가 rename 단독 선점을 red로 잡음" - fi - git checkout -- src/crewmail.mjs diff --git a/repro-mutate.mjs b/repro-mutate.mjs deleted file mode 100644 index 31210f1a..00000000 --- a/repro-mutate.mjs +++ /dev/null @@ -1,15 +0,0 @@ -// 임시 변이 스크립트 — mkdir 게이트를 제거하고 옛 rename 단독 선점으로 되돌린다(결함 하나만). -// 강화된 선점 경합 테스트가 이 변이를 윈도우에서 red로 잡는지 확인하는 프로브(조사 후 삭제). -import { readFileSync, writeFileSync } from 'node:fs'; - -const f = 'src/crewmail.mjs'; -const src = readFileSync(f, 'utf8'); -const start = src.indexOf(' const gate = '); -const endMarker = ' await dropGate();\n'; -const end = src.indexOf(endMarker, start); -if (start < 0 || end < 0) { console.error('변이 앵커를 못 찾음'); process.exit(2); } -const mutated = src.slice(0, start) - + " const claimedPath = `${item.full}.claimed`;\n try { await rename(item.full, claimedPath); } catch { continue; }\n" - + src.slice(end + endMarker.length); -writeFileSync(f, mutated); -console.log('mutated: mkdir 게이트 제거 → rename 단독 선점(출하본과 동형)'); diff --git a/repro-rename-race.mjs b/repro-rename-race.mjs deleted file mode 100644 index a033fa55..00000000 --- a/repro-rename-race.mjs +++ /dev/null @@ -1,55 +0,0 @@ -// 임시 재현 스크립트 — 윈도우 CI에서 crewmail 선점 rename의 상호배제가 깨지는지 실측. -// (조사용 — PR에 포함하지 않는다) -import { mkdtemp, rm, writeFile, rename, mkdir } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -const N = Number(process.env.REPRO_N ?? 2000); - -// ── 실험 1: 프리미티브 — 같은 소스를 두 주체가 동시에 rename ── -async function primitive() { - const dir = await mkdtemp(join(tmpdir(), 'repro-prim-')); - const dist = { 0: 0, 1: 0, 2: 0 }; - const codes = {}; - for (let i = 0; i < N; i++) { - const src = join(dir, `m${i}.json`); - const dst = `${src}.claimed`; - await writeFile(src, '{"x":1}'); - const rs = await Promise.allSettled([rename(src, dst), rename(src, dst)]); - const ok = rs.filter((r) => r.status === 'fulfilled').length; - dist[ok] += 1; - for (const r of rs) if (r.status === 'rejected') codes[r.reason.code] = (codes[r.reason.code] ?? 0) + 1; - if (ok === 2) console.log(`[prim] i=${i} 두 rename 모두 성공!`); - await rm(dst, { force: true }); await rm(src, { force: true }); - } - console.log('[prim] dist:', dist, 'reject codes:', codes); - await rm(dir, { recursive: true, force: true }); - return dist[2]; -} - -// ── 실험 2: 실제 경로 — deliverCrewMail 두 개 동시 (테스트와 동일, 단 sleep 0) ── -async function realpath() { - process.env.ARGO_ROOT = await mkdtemp(join(tmpdir(), 'repro-real-')); - const { paths } = await import('./src/workspace.mjs'); - const mod = await import('./src/crewmail.mjs'); - const WS = 'repro-ws'; - await mkdir(paths(WS).root, { recursive: true }); - await writeFile(paths(WS).company, JSON.stringify({ id: WS, name: 'r' })); - let doubles = 0; - for (let i = 0; i < Math.min(N, 500); i++) { - await mod.sendCrewMail(WS, { from: 'a', fromName: 'A', to: 'race', message: `r${i}` }); - let calls = 0; - const run = () => mod.deliverCrewMail(WS, async () => { calls += 1; await new Promise((r) => setTimeout(r, 5)); }); - await Promise.all([run(), run()]); - if (calls !== 1) { doubles += 1; console.log(`[real] i=${i} calls=${calls}`); } - // 잔재 청소 — 다음 회차 오염 방지 - await rm(join(paths(WS).root, 'mail'), { recursive: true, force: true }); - } - console.log(`[real] doubles=${doubles}/${Math.min(N, 500)}`); - await rm(process.env.ARGO_ROOT, { recursive: true, force: true }); - return doubles; -} - -const p = await primitive(); -const r = await realpath(); -console.log(`RESULT primitive-double=${p} realpath-double=${r}`);