/* * ★ 브로드캐스팅이 서버를 죽였다 — 그 기전을 실제 라이브러리로 재고, 우리 코드가 그것을 넘기지 않는지 본다. * * ── 무엇이 났나 (2026-08-27 10:24 · 실측) ─────────────────────────────────── * `RepeaterOverflowError: No more than 1024 pending calls to push are allowed on a single repeater.` * 가 브로드캐스팅 타이머 안에서 나서 프로세스가 내려갔다. 같은 예외로 2026-08-14 에도 죽었다. * * ── 기전 ──────────────────────────────────────────────────────────────────── * 구독의 대기열에는 버퍼가 없다. `pubsub.subscribe` 가 `new Repeater(fn)` 을 버퍼 인자 없이 만든다. * 그래서 발행하는 순간에 「다음 값을 달라」는 요청이 이미 걸려 있어야 바로 전달되고, 아니면 대기열에 * 쌓인다. 상한은 1024 이고 넘으면 예외가 난다. * * 값을 꺼내는 쪽은 비동기다. 그래서 **동기 반복문이 도는 중에는 한 건도 꺼내지 못한다.** 반복문으로 * N 건을 발행하면 첫 건만 전달되고 나머지 N−1 건이 전부 쌓인다. 브라우저가 아무리 빨라도 넘는다 — * 속도 문제가 아니라 꺼낼 틈을 주지 않는 것이 원인이다. * * ── 왜 try/catch 로 못 막나 ───────────────────────────────────────────────── * 2026-08-14 에 `publishGuarded` 가 `pubsub.publish` 를 try/catch 로 감쌌고, 커밋 제목이 「방송이 * 호스트를 죽이지 못하게」였다. 그것이 무력하다. 예외는 `EventTarget` 이 부른 리스너 안에서 나고, * 런타임은 리스너 예외를 호출자에게 주지 않고 uncaughtException 으로 보낸다. 이 테스트가 그것을 * 값으로 못 박는다 — 그때 검증하지 않아서 3주 동안 막힌 줄 알고 있었다. * * ── 왜 오늘 났나 ──────────────────────────────────────────────────────────── * 오늘 아침 chef 커넥터가 묻는 간격을 5초에서 30초로 올렸다(상대 서버 부담을 줄이려고). 요청 횟수는 * 줄지만 한 번에 오는 양이 6배가 된다. 초당 130건이 들어오는 원본에서 한 묶음이 3,900건이 되고, 그 * 묶음이 한 창에 몰려 반복문 하나가 1024 를 넘겼다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { spawnSync } from 'node:child_process' const ENGINE = join(import.meta.dirname, '..', 'server', 'engine', 'twin-engine.ts') const src = readFileSync(ENGINE, 'utf8') /** 저장소 뿌리 — 자식이 `graphql-yoga` 를 찾을 수 있어야 한다. */ const REPO = join(import.meta.dirname, '..', '..', '..') /** * 발행을 **자식 프로세스**에서 한다 — 「프로세스가 죽는다」를 종료 코드로 확인하려면 그래야 한다. * * 같은 프로세스에서 재면 예외가 테스트 러너에 잡혀 이 테스트의 실패로 보고된다. 그것도 사실이지만 * 확인하려는 것은 「감싸도 못 막고 프로세스가 내려간다」이므로 자식의 종료 코드로 판정한다. */ function runChild( count: number, opts: { consume: boolean; chunk?: number; subscribers?: number; watchAll?: boolean } ) { const subscribers = opts.subscribers ?? 1 const code = ` import { createPubSub, pipe, filter } from 'graphql-yoga' const pubsub = createPubSub() const watchAll = ${!!opts.watchAll} let pulled = 0, ordered = true, lastSeen = -1 if (${opts.consume}) { for (let s = 0; s < ${subscribers}; s++) { const stream = pipe(pubsub.subscribe('data'), filter(p => watchAll || p.tag === 'watched')) ;(async () => { for await (const p of stream) { /* 순서 확인은 첫 구독만 한다 — 여럿이 세면 서로의 값이 섞인다. */ if (s === 0) { pulled++ if (watchAll) { if (p.i !== lastSeen + 1) ordered = false lastSeen = p.i } } } })().catch(() => {}) } /* 소비자들이 첫 요청을 걸 틈을 준다 — 이것이 없으면 리스너조차 등록되지 않는다(지연 구독). */ await new Promise(r => setImmediate(r)) } let thrownToCaller = 0, sinceYield = 0 for (let i = 0; i < ${count}; i++) { try { pubsub.publish('data', { tag: (watchAll || i === 0) ? 'watched' : 'other-' + i, i }) } catch { thrownToCaller++ } const chunk = ${opts.chunk ?? 0} if (chunk && ++sinceYield >= chunk) { sinceYield = 0; await new Promise(r => setImmediate(r)) } } /* 남은 것을 다 꺼낼 틈을 준다 — 여기서 짧게 끊으면 「빠졌다」로 오판한다. */ for (let k = 0; k < 50; k++) await new Promise(r => setImmediate(r)) await new Promise(r => setTimeout(r, 100)) console.log(JSON.stringify({ thrownToCaller, pulled, ordered })) ` const r = spawnSync(process.execPath, ['--input-type=module', '-e', code], { cwd: REPO, encoding: 'utf8' }) const line = (r.stdout ?? '').trim().split('\n').filter(Boolean).pop() return { status: r.status, overflowed: /No more than 1024 pending calls to push/.test(r.stderr ?? ''), result: line ? (JSON.parse(line) as { thrownToCaller: number; pulled: number; ordered: boolean }) : null } } test('★ 구독자가 있고 한 차례에 1024건을 넘게 발행하면 프로세스가 죽는다 — 브라우저 속도와 무관하다', () => { const r = runChild(2000, { consume: true }) assert.ok(r.overflowed, '대기열이 넘쳐 예외가 나야 한다(이것이 서버를 죽인 그 예외다)') assert.notEqual(r.status, 0, '그 예외로 프로세스가 내려간다') }) test('★ 그 예외는 publish() 를 감싼 try/catch 로 잡히지 않는다 — 08-14 의 가드가 무력한 이유', () => { const r = runChild(2000, { consume: true }) assert.equal(r.result, null, '완주하지 못한다 — 결과를 낼 자리에 닿지 못한다') assert.ok(r.overflowed, '예외는 감싼 곳이 아니라 uncaughtException 으로 간다') }) test('★ 상한마다 루프를 비워 주면 넘지 않는다 — 이것이 고친 방법이다', () => { const r = runChild(5000, { consume: true, chunk: 200 }) assert.equal(r.status, 0, '청크마다 양보하면 프로세스가 살아 있다') assert.equal(r.overflowed, false, '대기열이 넘지 않는다') assert.ok((r.result?.pulled ?? 0) > 0, '소비자가 실제로 꺼내 간다') }) test('★ 동기 반복문 중에는 소비자가 꺼내지 못한다 — 속도가 아니라 틈이 없는 것이 원인이다', () => { /* 1024 아래로 두어 죽지 않게 하고, 그 사이에 몇 건이 꺼내졌는지 본다. */ const r = runChild(1000, { consume: true }) assert.equal(r.status, 0) assert.ok((r.result?.pulled ?? -1) <= 2, `꺼낸 것이 ${r.result?.pulled}건뿐이다 — 1000건을 발행하는 동안`) }) test('구독자가 없으면 아무것도 쌓이지 않는다 — 리스너가 없다', () => { const r = runChild(2000, { consume: false }) assert.equal(r.status, 0, '들을 사람이 없으면 대기열도 없다') assert.equal(r.overflowed, false) }) test('★ 엔진의 발행 반복문이 상한마다 루프를 비워 준다', () => { const at = src.indexOf('static async publishEntityData') assert.ok(at > 0, 'publishEntityData 가 async 여야 한다 — 양보할 수 없으면 상한을 지킬 수 없다') const body = src.slice(at, at + 6000) assert.match(body, /PUBLISH_CHUNK/, '한 차례의 상한에 이름이 있어야 한다') assert.match( body, /sinceYield \+= 0|\+\+sinceYield >= this\.PUBLISH_CHUNK[\s\S]{0,120}?setImmediate/, '상한에 닿으면 루프를 비워 줘야 한다' ) /* 상한은 1024 보다 넉넉히 작아야 한다 — 한 청크를 다 꺼내지 못해도 여유가 남게. */ const cap = Number(/static PUBLISH_CHUNK = ([0-9_]+)/.exec(src)?.[1]?.replace(/_/g, '')) assert.ok(cap > 0 && cap <= 512, `상한이 ${cap} 이다 — 1024 의 절반 아래여야 한다`) }) test('★ 발행을 중간에 그만두지 않는다 — 그만두면 그 태그가 낡은 값으로 남는다', () => { const at = src.indexOf('static async publishEntityData') const body = src.slice(at, at + 6000) /* 상한에 닿았을 때 `break`·`return` 으로 빠지면 남은 태그는 이 창에서 발행되지 않는다. */ assert.doesNotMatch(body, />= this\.PUBLISH_CHUNK[\s\S]{0,80}?(break|return)/, '상한에서 빠져나가지 않는다') }) test('★ 한 창이 겹쳐 돌지 않는다 — 같은 태그가 두 순서로 나가지 않게', () => { const at = src.indexOf('static flushLiveBroadcasts') assert.ok(at > 0, 'flushLiveBroadcasts 가 있어야 한다') const body = src.slice(at, at + 1200) assert.match(body, /if \(this\.flushInFlight\)/, '앞 창이 돌고 있으면 들어가지 않는다') assert.match(body, /flushOverruns\+\+/, '겹친 횟수를 센다 — 알리지 않고 넘기지 않는다') /* 발행을 기다려야 청크 사이의 양보가 뜻을 갖는다. */ assert.match(src, /await this\.publishEntityData\(inst\)/, '창이 발행을 기다린다') }) test('★ 발행 건수를 남긴다 — 다음에 이 판단을 추측으로 하지 않게', () => { const at = src.indexOf('static async publishEntityData') const body = src.slice(at, at + 6000) assert.match(body, /publishedTotal/, '누적 발행 건수를 든다') assert.match(body, /건 발행/, '상한을 넘긴 창은 건수를 로그에 남긴다') }) /* * ── 여기서부터: 상한 근처와 그 너머를 더 넓게 잰다 ──────────────────────────── * * 위 테스트가 「1024 를 넘으면 죽고, 200마다 양보하면 산다」를 못 박았다. 그런데 실제 창은 18,426건이고 * 구독은 하나가 아니다(`board-provider` 가 컴포넌트마다 하나씩 연다). 그 조건에서도 서는지 잰다. */ test('★ 실제 규모에서 선다 — 한 창 18,426건 · 청크 200', () => { /* 2026-08-27 11:11 실측값 그대로. 이 수가 상한의 18배라서 청크 없이는 반드시 죽는다. */ const r = runChild(18426, { consume: true, chunk: 200 }) assert.equal(r.status, 0, '실측 규모에서 프로세스가 살아 있어야 한다') assert.equal(r.overflowed, false) }) test('★ 구독이 여러 개여도 선다 — 화면은 컴포넌트마다 구독을 하나씩 연다', () => { /* * 지금 구조에서는 발행 하나가 **구독마다** 큐를 지나간다. 그래서 구독 수가 늘면 밀리는 총량도 * 그만큼 늘고, 양보 한 번에 소비자 여럿이 나눠 꺼내야 한다. 그 조건에서도 상한을 넘지 않아야 한다. */ const r = runChild(5000, { consume: true, chunk: 200, subscribers: 20 }) assert.equal(r.status, 0, '구독 20개에서도 살아 있어야 한다') assert.equal(r.overflowed, false) }) test('★ 한 실행 차례의 경계는 1,025건이다 — 재서 확인한 값이다', () => { /* * ── 측정값 (2026-08-27) ───────────────────────────────────────────────────── * 청크 1,024 · 1,025 산다 * 청크 1,026 죽는다 * 구독 수(1·5·20) 경계를 바꾸지 않는다 — 구독마다 자기 대기열이 따로다 * * 1,025 까지 사는 이유: 청크가 시작될 때 소비자의 요청이 하나 걸려 있어 첫 건은 바로 전달되고, * 그 뒤 1,024건이 쌓인 상태에서 검사를 통과한다. 1,026번째에 상한에 닿는다. * * 처음에 「청크가 상한과 같으면 위험하다」고 단정했는데 틀렸다. 재서 고쳤다. */ assert.equal(runChild(5000, { consume: true, chunk: 1025 }).status, 0, '1,025 는 산다') const over = runChild(5000, { consume: true, chunk: 1026 }) assert.ok(over.overflowed, '1,026 은 넘친다') assert.notEqual(over.status, 0, '그리고 프로세스가 내려간다') }) test('★ 그래도 여유를 둔다 — 같은 실행 차례에 다른 발행처가 함께 넣는다', () => { /* * 경계가 1,025 라도 그것에 붙여 쓰지 않는다. 이 채널에 발행하는 곳이 우리만이 아니다 — * `integration-base` 의 상태 등록 변경과 `shell` 의 진행률 보고가 같은 채널을 쓴다. 그것들이 * 같은 실행 차례에 끼어들면 우리 몫에 더해진다. * * 엔진의 값이 200 이다(경계의 1/5). 이 테스트는 그 값이 여유 있는 쪽에 있는지만 본다. */ const cap = Number(/static PUBLISH_CHUNK = ([0-9_]+)/.exec(src)?.[1]?.replace(/_/g, '')) assert.ok(cap > 0 && cap <= 512, `상한이 ${cap} 이다 — 경계(1,025)의 절반 아래여야 한다`) }) test('청크가 넉넉히 작으면 구독이 늘어도 여유가 남는다 — 엔진의 200이 그 자리에 있다', () => { const r = runChild(5000, { consume: true, chunk: 200, subscribers: 50 }) assert.equal(r.status, 0) assert.equal(r.overflowed, false) }) test('★ 발행한 것이 하나도 빠지지 않는다 — 청크로 나눈 것이 값을 잃지 않았음을 확인한다', () => { /* * 청크는 시점만 나눈다. 건수·내용·순서가 그대로여야 한다. 그것을 확인하지 않으면 「죽지 않는다」만 * 알고 「제대로 전달된다」는 모르는 상태가 된다. */ const r = runChild(3000, { consume: true, chunk: 200, watchAll: true }) assert.equal(r.status, 0) assert.equal(r.result?.pulled, 3000, '발행한 3,000건이 그대로 도착해야 한다') assert.equal(r.result?.ordered, true, '순서도 그대로여야 한다') })