/* * 재기동을 넘어 **도는 트윈이 계속 돈다** — 그리고 되살리지 못하면 그렇게 말한다. * * ── 실측 (2026-08-14) ─────────────────────────────────────────────────────── * 서버를 한 번 재기동하면 등록부는 `running` 이라 말하는데 **아무 커널도 돌지 않았다**. * `bootstrap()` 은 상태만 되찾아 `recovered` 에 담았고, 커널을 세우는 것은 명시 mutation 뿐이었다. * 미러 트윈에서는 계측까지 끊겼다 — `startReferenceLiveFeed` 를 부르는 곳이 `importTwinReference` * 하나뿐이어서, 붙어 있던 커넥터가 알리지 않고 떨어졌다(오류도 로그도 없이). * * 이 시험이 지키는 것 넷: * ① 부팅이 런타임까지 되살린다(상태 복구만으로는 도는 트윈이 아니다) * ② 모드를 지어내지 않는다 — 미러였던 것은 미러로(시뮬로 되살리면 없던 움직임을 만든다) * ③ 되살리지 못한 행은 `stopped` 로 적는다(우리가 고치려던 거짓말이 남지 않게) * ④ 부팅 훅이 **엔진 다음에** 피드를 다시 붙인다 * * 엔진과 레퍼런스 계층은 DB·shell 을 물고 있어 단위 시험이 불러올 수 없다. 그래서 **배선의 모양**을 * 소스로 지킨다 — 이 부류의 결함은 「부르는 곳이 없다」는 모양이고, 그건 소스에서 보인다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' const engine = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8') const live = readFileSync(fileURLToPath(new URL('../server/service/reference/reference-live.ts', import.meta.url)), 'utf-8') const boot = readFileSync(fileURLToPath(new URL('../server/index.ts', import.meta.url)), 'utf-8') /** 그 정적 메서드의 본문만 — 다음 메서드 선언까지. */ function methodOf(src: string, name: string): string { const at = [`static ${name}(`, `static async ${name}(`].map(sig => src.indexOf(sig)).filter(i => i >= 0).sort((a, b) => a - b)[0] assert.ok(at !== undefined, `${name} 가 없다 — 이름이 바뀌었으면 이 검사도 함께 고친다`) const ends = ['\n static ', '\n private static ', '\n /**'].map(m => src.indexOf(m, at + 10)).filter(i => i >= 0) const end = ends.sort((a, b) => a - b)[0] return src.slice(at, end === undefined ? undefined : end) } test('부팅이 런타임까지 되살린다 — 상태 복구만으로는 도는 트윈이 아니다', () => { const body = methodOf(engine, 'bootstrap') assert.match(body, /resumeRow\(row\)/, 'bootstrap 이 행마다 재개를 시도해야 한다') /* 예전 모양: 스냅샷을 `recovered` 에 담고 `continue` 로 끝났다 — 그것으로 되돌아가지 않게. 한 줄로 못 박지 않는다(2026-08-21 에 되살리기 사이 양보가 들어와 줄 모양이 바뀌었다) — **행 전부를 돌며 재개한다**는 사실만 본다. */ assert.match(body, /for \(const row of rows\)[\s\S]{0,400}?await this\.resumeRow\(row\)/, '복구 뒤 재개 루프가 있어야 한다') /* 되살리기 사이에 루프를 비워 준다 — 없으면 부팅 내내 HTTP 가 서지 않는다. */ assert.match(body, /BOOT_YIELD_MS/, '되살리기 사이에 양보가 있어야 한다') }) test('모드를 지어내지 않는다 — 미러였던 것은 미러로 되살린다', () => { /* * ── 갈림은 **문 안**에 있다 (2026-08-20) ────────────────────────────────── * 예전에는 이 판단이 `resumeRow` 에 있었다. 그래서 부팅으로 살아난 미러는 관측 구동이었지만, 사람이 * 화면에서 시작한 미러는 **시뮬로 떴다** — 그 문(`startFromRegistry`)에는 갈림이 없었기 때문이다. * 시뮬로 뜬 미러는 유입을 한 건도 받지 못하고(`ingestLive` 가 0 을 돌려준다), 화면에는 「running」이라 * 적힌다. 같은 선언이 두 결과를 내지 않도록 갈림을 문 안으로 옮겼고, 이 시험이 그 자리를 지킨다. */ const resumeRow = methodOf(engine, 'resumeRow') assert.match(resumeRow, /await this\.startFromRegistry\(domainId, instanceId\)/, '부팅도 같은 문을 지난다') const door = methodOf(engine, 'startFromRegistry') assert.match(door, /const policy = restartPolicy \?\? readRestartPolicy\(/, '선언된 값을 엄격히 읽는다') assert.match(door, /if \(policy === 'resync'\)/, '문이 선언된 모드를 보고 갈라야 한다') /* * 미러 가지 안을 그대로 떠서 본다 — 미러를 시뮬로 세우면 관측 트윈이 **스스로 물건을 옮긴다** * (없던 움직임을 만드는 것이다). 단어의 거리로 재면 로그·주석에 걸려 헛돈다. */ const at = door.indexOf("if (policy === 'resync')") const mirrorBranch = door.slice(at, door.indexOf('\n }', at)) assert.match(mirrorBranch, /return this\.startLive\(/, '미러 가지는 관측 구동으로 세우고 거기서 끝난다') assert.equal(mirrorBranch.includes('this.start('), false, '미러 가지에서 시뮬 기동을 부른다 — 없던 움직임을 만든다') /* 미러도 공간의 시각 기준을 받는다 — 교대의 HH:MM 을 어느 기준으로 읽나(부팅과 같은 규칙). */ assert.match(mirrorBranch, /withSpaceTimeBase\(/) }) test('reset 은 선언대로 저널을 비운다 — 씨앗 재현은 백지에서 시작한다', () => { /* * 「seed 재현」은 백지에서 다시 시작한다는 뜻인데, 예전에는 그 선언이 구현되지 않아 `reset` 트윈이 * 사실상 이어졌다. 사실을 지우는 동작이라 순서를 지켜 켰다: 먼저 사람이 정책을 다시 선언할 문을 만들고 * (`setTwinRestartPolicy`), 이력을 두고 볼 트윈을 `resume` 으로 옮긴 뒤에 켰다. */ const door = methodOf(engine, 'startFromRegistry') const at = door.indexOf("if (policy === 'reset')") assert.ok(at > 0, '문이 reset 을 갈라야 한다') const branch = door.slice(at, door.indexOf('\n }', at)) assert.match(branch, /resetJournal\(domainId, instanceId\)/, '저널을 비운다') /* 벤치 사본은 예외다 — 누군가 지켜보는 실험의 기록을 기동이 지우면 실험이 사라진다. */ assert.match(branch, /purpose !== 'bench'/, '벤치 실험의 기록은 지우지 않는다') /* 웜스타트보다 **먼저** 지워야 한다 — 뒤에 지우면 이미 옛 상태를 읽어 실은 뒤다. */ assert.ok(at < door.indexOf('this.recovered[key]'), '지우기가 웜스타트보다 앞에 있어야 한다') }) test('저널만 지우면 씨앗 재현이 아니다 — 체크포인트·사슬도 무효로 만든다', () => { /* * 웜스타트는 체크포인트를 **먼저** 본다. 저널만 지우면 백지로 시작한 트윈에 옛 상태가 되살아나 * 「저널은 비었는데 재고가 있는」 트윈이 된다. 시간여행이 딛는 사슬 캐시도 지워진 리비전을 가리킨다. */ const body = methodOf(engine, 'resetJournal') assert.match(body, /SNAPSHOT_CACHE_ID[\s\S]{0,120}state: null/, '체크포인트를 「없음」으로 덮는다') assert.match(body, /CHAIN_INDEX_CACHE_ID[\s\S]{0,80}revisions: \[\]/, '사슬 색인도 비운다') /* 실패를 삼키지 않는다 — 무효화가 실패하면 다음 기동이 옛 상태를 물려받는다. */ assert.match(body, /catch[\s\S]{0,120}twinWarn/) }) test('정책을 다시 선언하는 문이 있다 — 없으면 선언과 쓰임이 영원히 어긋난다', () => { const src = readFileSync( fileURLToPath(new URL('../server/service/twin-lifecycle/twin-lifecycle-mutation.ts', import.meta.url)), 'utf-8' ) assert.match(src, /async setTwinRestartPolicy\(/, '만든 뒤에 고칠 길이 있어야 한다') /* 값은 엄격히 읽는다 — 모르는 말을 기본값으로 메우면 그 트윈의 이력이 남을지가 알리지 않고 결정된다. */ assert.match(src, /readRestartPolicy\(restartPolicy, /) /* 도는 런타임을 알리지 않고 갈아 끼우지 않는다 — 다르다는 사실을 답에 담는다. */ assert.match(src, /appliesAtNextStart/) }) test('되살리지 못한 행은 stopped 로 적는다 — 「도는 중」이라 말하는 채로 두지 않는다', () => { const resume = methodOf(engine, 'resumeRow') assert.match(resume, /catch[\s\S]{0,80}markStopped/, '실패를 삼키면 등록부가 계속 거짓을 말한다') const mark = methodOf(engine, 'markStopped') assert.match(mark, /status: 'stopped'/, '등록부에 실제로 적어야 한다') assert.match(mark, /twin(Warn|Error)/, '되살리지 못한 사실은 로그에도 남아야 한다') }) test('벤치 사본은 무인으로 되살아나지 않는다 — 실험은 재기동을 넘기지 못한다', () => { const body = methodOf(engine, 'resumeRow') assert.match(body, /purpose === 'bench'/, '격리 사본을 갈라야 한다') assert.match(body, /bench[\s\S]{0,200}markStopped/, '되살리지 않았으면 등록부도 그렇게 적어야 한다') }) test('피드 재개는 등록부가 「도는 미러」라고 말하는 것만 — 멈춘 것을 다시 켜지 않는다', () => { assert.match(live, /export async function resumeReferenceLiveFeeds/, '재개 함수가 있어야 한다') assert.match(live, /r\.status === 'running' && r\.restartPolicy === 'resync'/, '도는 미러만 골라야 한다') assert.match(live, /discoverSites/, '사이트 서술자는 다시 찾아서 얻는다(hint 를 남기지 않았다)') /* 붙지 않은 미러는 「도는데 아무것도 오지 않는」 트윈이다 — 알리지 않고 넘기지 않는다. */ assert.match(live, /NOT reattached/, '못 붙인 것은 이유와 함께 남겨야 한다') }) test('부팅 훅이 엔진 다음에 피드를 붙인다 — 순서가 뒤집히면 없는 커널에 계측을 붓는다', () => { assert.match(boot, /resumeReferenceLiveFeeds/, '부팅 훅이 재개를 불러야 한다(부르는 곳이 없으면 없는 기능이다)') const engineAt = boot.indexOf('TwinEngine.bootstrap()') const feedAt = boot.indexOf('resumeReferenceLiveFeeds()') assert.ok(engineAt >= 0 && feedAt > engineAt, '엔진 복구가 피드 재개보다 앞이어야 한다') }) /* * ── 부팅 재개가 드러낸 것 (2026-08-14 실측) ───────────────────────────────── * 트윈 14개가 실제로 돌기 시작하자 호스트가 죽었다. 원인은 재개가 아니라 **브로드캐스팅 두 가지**였다: * ① 시뮬은 delta 마다 곧바로 전 상태를 투영해 브로드캐스팅했다 — 한 tick 이 사실을 수백 개 내면 그 반복이 * 이벤트 루프를 35초 막았고(단계 합은 32ms 였다), 그 사이 구독자는 아무것도 빼내지 못했다. * ② 밀린 push 가 1024를 넘자 pubsub 이 오류를 냈고, 타이머 콜백을 타고 올라온 그 예외가 프로세스를 끝냈다. * 둘 다 재개 없이도 있던 결함이다 — 재개가 그것을 매일 아침 만나게 만들었을 뿐이다. */ test('시뮬 브로드캐스팅도 모아서 한 번 — delta 마다 전 상태를 투영하지 않는다', () => { const at = engine.indexOf("if (msg.kind === 'delta')") assert.ok(at >= 0, 'sim delta 처리 자리가 없다') const body = engine.slice(at, at + 2600) assert.match(body, /inst\.dirty = true/, 'dirty 로 표시하고') assert.match(body, /ensureBroadcastCoalescer\(\)/, '주기 flush 에 맡겨야 한다') assert.equal(/this\.publishEntityData\(inst\)/.test(body), false, 'delta 마다 곧바로 브로드캐스팅하는 모양으로 되돌아갔다') }) test('flush 가 시뮬도 본다 — 브로드캐스팅과 저널은 둘 다, state 채널은 라이브만', () => { /* * 창의 본문은 `flushBroadcastWindow` 로 옮겼다(2026-08-27). `flushLiveBroadcasts` 는 겹쳐 도는 것을 * 막는 문만 남았다 — 발행이 청크로 나뉘어 한 창이 주기를 넘길 수 있게 됐기 때문이다. */ const body = methodOf(engine, 'flushBroadcastWindow') assert.equal(/mode !== 'live'\) continue/.test(body), false, '시뮬을 건너뛰는 모양으로 되돌아갔다') assert.match(body, /const isLive = inst\.mode === 'live'/, '모드는 가르되 둘 다 브로드캐스팅해야 한다') /* * **저널은 두 구동이 같은 문을 쓴다** (2026-08-20, ingest-scale §7.1). 예전에는 시뮬이 델타마다 단건 * 저장을 했고 그것이 규모에서 호스트를 먹었다(CPU 100%·질의 8~14초). 그래서 flush 가 시뮬의 저널도 * 흘린다 — 그 호출이 `if (!isLive) continue` **앞**에 있어야 시뮬 몫이 빠지지 않는다. */ const flushAt = body.indexOf('this.flushJournal(inst)') const liveOnlyAt = body.indexOf('if (!isLive) continue') assert.ok(flushAt >= 0, '저널을 흘리는 자리가 없다') assert.ok(liveOnlyAt >= 0, 'state 채널을 라이브로 가르는 자리가 없다') assert.ok(flushAt < liveOnlyAt, '저널 flush 가 라이브 전용 구간 뒤로 밀리면 시뮬의 사실이 쌓인 채 남는다') /* state 채널은 시뮬이 자기 콜백에서 이미 보낸다 — 여기서 또 보내면 같은 신호가 두 번 간다. */ /* The payload is built by the channel since 2026-09-24 (it carries the domain); look for that call. */ const stateSendAt = body.indexOf('twinStatePayload(inst.domainId, { instanceId: inst.id') assert.ok(stateSendAt >= 0, 'state 채널로 보내는 자리가 없다') assert.ok(stateSendAt > liveOnlyAt, 'state 채널 브로드캐스팅은 라이브 구간에 있어야 한다') }) test('구독자 하나가 호스트를 죽이지 못한다 — 그리고 못 보낸 것을 보낸 것으로 적지 않는다', () => { /* 직접 브로드캐스팅은 **가드 안에 하나**여야 한다 — 그 밖의 호출은 예외가 그대로 올라가는 길이다. */ const guardBody = methodOf(engine, 'publishGuarded') assert.equal((engine.match(/pubsub\.publish\(/g) ?? []).length, 1, '가드를 거치지 않는 직접 브로드캐스팅이 남아 있다') assert.match(guardBody, /pubsub\.publish\(/, '그 하나는 가드 안에 있어야 한다') /* * ── 이 가드가 무엇을 못 하는지 (2026-08-27 측정으로 확인) ─────────────────── * 이 try/catch 는 **대기열 넘침을 막지 못한다.** 그 예외는 `EventTarget` 이 부른 리스너 안에서 나고 * 런타임은 그것을 호출자에게 주지 않는다(uncaughtException 으로 간다). 그것을 막는 것은 한 차례에 * 발행하는 수를 상한 아래로 두는 것이고, 그 규율은 `broadcast-queue-overflow.test.ts` 가 지킨다. * * 그래도 남긴다: 직렬화 실패처럼 `publish` 가 직접 던지는 경우를 잡고, 못 보낸 태그의 시그니처를 * 되돌린다(남겨 두면 그 태그가 오류 없이 영원히 낡는다). */ assert.match(guardBody, /catch/, 'publish 가 직접 던지는 경우는 잡아야 한다') assert.match(guardBody, /twinWarn/, '못 보낸 사실은 남아야 한다(삼키면 「보냈는데 낡았다」가 된다)') /* 시그니처를 남겨 두면 다음 주기에 「무변화」로 건너뛰고 그 태그는 영원히 낡는다. */ assert.match(engine, /if \(!this\.publishGuarded\('data'[\s\S]{0,80}sigs\.delete\(tag\)/, '실패한 태그의 시그니처를 되돌려야 한다') }) /* * ── 굶김 안전망 (2026-08-14) ───────────────────────────────────────────────── * 시뮬 틱은 메인 이벤트 루프에서 돈다 — 한 트윈의 틱이 길어지면 그 시간만큼 호스트 전체가 멈춘다 * (실측 34.9초). 브로드캐스팅 반복은 걷어냈지만 커널 틱 자체는 여전히 메인 루프에 있고, 근본 해결(분산)은 * 이연됐다. 그 사이의 안전망이 이것이다: **호스트를 굶기는 트윈은 멈춘다.** */ test('굶김은 예산과 다른 문턱으로 판정한다 — 조금 느린 트윈을 내리지 않는다', () => { const body = methodOf(engine, 'guardStarvation') assert.match(body, /this\.TICK_MS \* this\.STARVE_FACTOR/, '굶김 문턱은 틱 간격의 배수다') /* 예산(500ms)으로 내리면 계기판이 말해야 할 「조금 느림」에 트윈이 죽는다. */ assert.equal(/budgetMs/.test(body), false, '예산으로 내리는 모양 — 경고와 정지를 섞지 않는다') assert.ok(/STARVE_FACTOR = \d+/.test(engine) && /STARVE_STREAK = \d+/.test(engine), '문턱과 연속 횟수가 상수로 서 있어야 한다') }) test('한 번으로 내리지 않고, 한 번 내려오면 연속이 끊긴다', () => { const body = methodOf(engine, 'guardStarvation') assert.match(body, /streak < this\.STARVE_STREAK\) return/, '연속이 차기 전에는 멈추지 않는다') /* 웜스타트 직후 첫 틱은 원래 무겁다(복구한 상태를 처음 계산한다) — 그것으로 내리면 재개가 무의미해진다. */ assert.match(body, /this\.starveStreak\.delete\(key\)[\s\S]{0,40}return/, '문턱 아래면 연속을 끊어야 한다') }) test('멈추는 길은 하나 — 타이머만 끄면 등록부가 계속 「도는 중」이라 말한다', () => { const guard = methodOf(engine, 'stopWithNote') assert.match(guard, /this\.stop\(domainId, id\)/, '공용 stop 을 써야 한다(스냅샷·피드 정리·등록부가 그 안에 있다)') /* 예전 tick 실패 경로는 `clearInterval` 만 했다 — 그 모양으로 되돌아가지 않게. */ const tick = methodOf(engine, 'tickGuarded') assert.equal(/clearInterval\(inst\.timer\)/.test(tick), false, '타이머만 끄는 모양으로 되돌아갔다') assert.match(tick, /stopWithNote\(/, '실패도 이유와 함께 멈춰야 한다') }) test('왜 멈췄는지 코드로 남고, 다시 세우면 지운다', () => { assert.match(engine, /export interface StopNote/, '이유는 문장이 아니라 코드다(화면이 옮긴다)') assert.match(engine, /code: 'starved', params: \{ streak/, '굶김은 근거 수치와 함께') assert.match(engine, /code: 'tick-failed', params: \{ reason/, '틱 실패는 원문 그대로(옮길 어휘가 아니다)') assert.match(methodOf(engine, 'list'), /stopNote: this\.stopNotes\.get\(/, '목록이 그 사실을 함께 내야 한다') /* 남겨 두면 다시 돌고 있는 트윈이 「굶겨서 멈췄다」고 말한다. */ assert.match(engine, /this\.stopNotes\.delete\(key\)/, '다시 세울 때 지워야 한다') }) /* * ── 같은 현장 이웃 설비 상태 전달 (2026-08-14) ────────────────────────────── * 시뮬 에너지 트윈은 계측이 없으므로 스스로 부하를 만든다. 그 재료 하나가 「무엇이 돌고 있나」인데, * 그것은 같은 현장 공장 트윈이 아는 사실이다. 호스트는 **누가 이웃인지**만 알려 주고, 무엇을 읽어 * 어떻게 부하로 바꿀지는 커널이 정한다 — 호스트가 kW 를 계산해 주면 규칙이 어디에도 안 적힌 채 * 트윈마다 갈라진다. */ test('이웃 상태는 커널이 요청할 때만, 같은 현장에서만 전달한다', () => { const body = methodOf(engine, 'feedPeerEquipment') assert.match(body, /observePeerEquipment !== 'function'\) return/, '이웃을 읽지 않는 커널에는 넘기지 않는다') assert.match(body, /other\.spaceId !== inst\.spaceId\) continue/, '같은 현장만 이웃이다') assert.match(body, /other === inst/, '자기 자신은 이웃이 아니다') /* 상태만 넘긴다 — 스냅샷을 통째로 넘기면 매 tick 상태 크기에 비례한 비용을 치른다. */ assert.equal(/snapshot\(\)/.test(body), false, '스냅샷을 넘기는 모양으로 되돌아갔다') assert.match(body, /status: String/, '상태만 실어 보낸다') }) test('현장이 없으면 이웃 목록을 비운다 — 옛 상태가 남아 부하를 만들지 않게', () => { const body = methodOf(engine, 'feedPeerEquipment') assert.match(body, /!inst\.spaceId[\s\S]{0,120}observePeerEquipment\(\[\]\)/, '빈 목록으로 덮어야 한다') }) test('tick 이 이웃 상태를 먼저 싣는다 — 커널이 그것을 읽어 부하를 만든다', () => { const tick = methodOf(engine, 'tickGuarded') const feedAt = tick.indexOf('feedPeerEquipment') const tickAt = tick.indexOf('runtime.tick(') assert.ok(feedAt >= 0 && tickAt > feedAt, '이웃 상태를 실은 뒤에 tick 해야 한다') }) /* * ── 시뮬 트윈의 시각축 (2026-08-15) ───────────────────────────────────────── * 커널의 기본 기준점은 상수(2026-01-01)다. 호스트가 그것을 실제 시각으로 옮기지 않으면 시뮬 트윈의 * 저널 시각이 **재기동마다 되감긴다** — 며칠 치 사건이 전부 같은 분에 몰리고, 실 시각으로 창을 자르는 * 성과·이력 질의에는 그 트윈이 보이지 않는다. 수요 구간(15분)도 가짜 시각 위에 서게 된다. */ test('시뮬 기동이 커널의 시각 기준점을 지금으로 맞춘다', () => { const body = methodOf(engine, 'start') assert.match(body, /setClockOrigin\?\.\(Date\.now\(\)\)/, '기동 순간의 실제 시각을 줘야 한다') /* 커널을 세운 **직후**여야 한다 — 시계가 흐른 뒤에 옮기면 그전 사실과 시간축이 어긋난다. */ const at = body.indexOf('setClockOrigin') const runtimeAt = body.indexOf('new TwinRuntime(') assert.ok(at >= 0 && runtimeAt > at, '런타임을 세우기 전에 기준점을 맞춘다') })