버그는 브라우저에 없었다
네 번째 도구는 브라우저 게임 —— 2048, 텍스트로 그려지고 화살표 키로 조종되며, 사람이 실제로 놀 수 있는 라이브 데모일 작정이었다. bindings에 없던 한 frontend 능력을 더했다:키보드 입력. 하지만 보드가 망가지기 시작했고, 그 scramble을 쫓자 아무도 예기치 않은 곳으로 이끌렸다 —— 브라우저가 아니라, 한 backend조차 아니라, 컴파일러가 줄곧 nested 함수를 lift해 온 그 방식의 버그로, 프로그래밍에서 가장 흔한 습관에 이끌려:루프를 `go`라 이름 짓기.
네 번째 도구는 커맨드라인을 완전히 떠났다. 2048 —— 그 작은 4×4 슬라이딩 타일 게임 —— 이 web 브라우저에서 돌고, 보드가 평문 텍스트로 그려지고 화살표 키로 조종된다. native 능력을 각각 강제한 처음 셋과 달리, 이것에는 두 번째 일이 있었다:링크를 따라가면 누군가가 실제로 놀 수 있는 데모일 것, counter나 echo보다 훨씬 설득력 있는 언어 playground로의 입구다. 언어를 WebAssembly로 컴파일해 페이지에 잇는 기존 frontend bindings를 재사용했고, 그리고 그 bindings가 아직 갖지 않은 딱 하나를 요했다 —— 키보드를 듣는 술이다.
컴파일러를 건드리지 않고 자란 능력
한 gap은 키보드 입력이었다:bindings는 클릭을 들을 수 있어도 키 누름을 들을 수 없었고, 그리고 키는 클릭이 나르지 않는 데이터를 나른다 —— 어느 키인가. 그것을 더한다는 것은, 페이지 위의 키 누름을 듣고, 키의 이름을 언어로 쓰인 콜백에 건네는 새 binding을 뜻했다. 밖에서 언어의 closure로 문자열을 건네는 경로는 bindings가 그때껏 한 번도 행사한 적이 없었고, 그것을 세우는 것이 마일스톤의 전부였다. 조용히 주목할 만한 것은 변경이 어디에 살았는가다:완전히 bindings 층 안에서, 컴파일러로의 변경은 일절 없이, 그러므로 언어의 새 릴리스도 없었다. 그 자체가 하나의 발견이다 —— frontend 인터페이스가, 코어를 어지럽히지 않고 새 힘을 기를 수 있을 만큼 깔끔하게 층을 이루고 있다, 고 말한다. 게임의 요구를 그 단일한 gap에 유지하기 위해, 랜덤한 새 타일은, 이 backend에 있으리란 보장이 없는 builtin에 손을 뻗는 게 아니라, 수를 seed로 한 평문의 언어 코드로 쓰인 작은 의사난수 생성기로 생성되었다. gap 하나, 이름 짓고 메우고;다른 모든 것은 우회되었다.
보드가 망가지기 시작했다
입력이 돌고, 슬라이드 로직이 interpreter에서 옳다고 증명되어 —— 2와 2는 4로 병합하고, 타일은 벽을 향해 압축한다 —— 게임은 WebAssembly로 컴파일되어 브라우저에서 돌았고, 보드가 틀리게 나왔다. 타일이 룰이 금하는 곳에 착지하고;공백・공백・2로 읽어야 할 행이 2・공백・공백・1로 나왔다. 로직은 증명적 으로 옳았고 —— interpreter에서 한 수마다 통과했다 —— 그래서 의심은 새롭고 낯선 부분에 떨어졌다: 브라우저, WebAssembly backend, 메모리를 alloc하는 것과 closure에서 변수를 capture하는 것의 상호작용. 첫 추측은 이것이 WebAssembly 고유의 결함, 보드를 capture하면서 list를 alloc하는 nested 함수를 그 backend가 다루는 방식의 무언가, 라는 것이었다. 그 추측은 타당하게 느껴졌다. 그것은 또한 틀렸고, 확인하는 유일한 길은 추측을 그만두고, 실패를 최소의 형태로 환원하는 것이었다.
진짜 원인까지 이분 탐색한다
브라우저, 게임, 그래픽스를 벗겨 내자, 버그는 그 평명함에서 놀라운 것으로 환원되었다. if의 두 분기를
포함한 한 함수를 취하고, 각 분기에 그 자신의 작은 재귀 헬퍼를 준다 —— 그리고, 세계에서 가장 자연스러운
습관에 따라, 두 헬퍼를 go라 이름 짓는다. 그것들을 closure 경유로 부르면, 프로그램은 틀린 쪽을
돌린다:두 분기가 두 번째 go로 해결된다. 그리고 이것은 WebAssembly의 버릇이 전혀 아니었다 —— native
C build가 딱 같은 방식으로 틀렸고, interpreter만이 옳은 채였다. 그것은 두 컴파일 backend에 존재하는
cross-backend의 정당성 버그로, 그동안 줄곧 컴파일러에 앉아 있었다. 원인은 nested 함수가 톱레벨로
lift되는 방식에 있었다:각각이 source 이름을 키로 한 lookup 표에 등록되므로, 같은 함수 안의 두 번째
go가 첫 번째를 덮어쓰고, go라 쓴 모든 호출 지점이 마지막에 등록된 쪽으로 해결된다. lift된 함수
자체는 별개의 내부 이름을 부여받았다;충돌한 것은, 여전히 원래 이름을 키로 한 호출 지점의 lookup
이었다. 게임의 보드가 망가진 것은, 좌우와 상하의 move 로직이 각각 내부 루프를 포함하고, 그리고 ——
당연히 —— 그 루프의 각각이 go라 이름 지어져 있었기 때문이다.
왜 흔한 이름이 완벽한 함정이었나
이것은, 유발하는 형태를 아무도 쓰지 않는 동안 딱 그동안만 숨는 종류의 버그이고, 그리고 유발하는
형태는 거의 공격적으로 흔하다. 재귀 헬퍼를 go나 loop라 이름 짓는 것은 기이하지 않다;생각 없이
하는 일이다. 한 함수에 그것이 둘, closure 경유로 도달되는 것은, 선행하는 어느 프로그램도 부딪히지
않을 만큼 드물고, 시간 문제일 만큼 평범했다. 그것은, 다른 함수 안의 두 동명 헬퍼가 충돌한 선행
버그의 근친이었다 —— 이것은 단일 함수 안의 같은 충돌이다 —— 하지만 수정은 옛것의 단순한 복제가
아니었다. 옳은 수리는, 어느 backend도 그것을 보기 전에 도는, 프로그램으로의 한 패스로, nested 함수를
이름이 실제로 충돌할 때에만 리네임한다:첫째는 이름을 지키고, 뒤의 각 재사용은 유일하게 된다.
backend로의 분기 전에 돌므로, 한 패스가 native와 WebAssembly의 출력을 한 번에 수정하고, 그리고 진짜
충돌에만 손대므로, 한 번도 충돌하지 않은 모든 프로그램은 바이트 단위로 불변으로 남는다 —— 특정 함수
이름을 주장하는 모든 테스트를 포함해.
게임은 그 뒤 ship했다. 브라우저에서 라이브로 놀 수 있고, 텍스트로 그려지고, 화살표 키로 조종되고,
작은 생성기에서 타일을 낳는다. 공언된 목표 —— 누군가가 실제로 놀 수 있는 도달 가능한 데모 —— 는
달성되었다. 하지만 더 깊은 결과는 탐정담이었다:frontend를 테스트하기 위해 세워진 도구가, 망가진
보드를 통해, frontend와 아무 관계도 없는 버그를 찾았다, 두 컴파일 backend에 걸쳐 컴파일러에 잠복하며,
누군가가 go라는 두 루프를 쓰기를 기다리던 버그를. 앞선 세 native 도구는 C backend를 단련했다;
이것은 WebAssembly backend를 단련했다 —— 그리고 backend의 수정보다 나은 것을 건넸다, 모두가 루프를
이름 짓는 대로 자신의 루프를 이름 짓는 모든 프로그램을 지키는 정당성의 수정을. 그것이 네 도구의
마지막이었고, 이 Part는 다음에, 모든 것 중 가장 기묘한 발견으로 닫는다:언어로의 수정이, 그것으로
쓰인 프로그램을 조용히 망가뜨렸다, 라는.