Files
coollang/dogfoods/FINDINGS.md
T
coolguyandClaude Opus 5 01d090b065 move: capability가 struct 필드로 복제되던 구멍을 막는다 (보안 정리 ii)
"발견들 다 고쳤나"를 확인하려고 전부 다시 돌려보다 드러났다.

  pub struct Wrapper { pay: Pay }
  pub fn duplicate(w: Wrapper) effects {Pay.charge} {
      consume(w.pay)
      consume(w.pay)      // 같은 capability를 두 번 소비 — 통과했다
  }

w가 빌린 값인데도 통과했다. 즉 "safe code에서 capability는 복제·위조되지
않는다"가 깨져 있었다. 이 세션에서 찾은 것 중 가장 심각하다.

원인은 단순하다. E_field가 빌린 값을 돌려주는데 Move 문맥에서 그것을
검사하는 곳이 E_ident 분기에만 있었고, 필드 접근은 그 분기를 지나가지
않는다. 규칙("v0에 부분 move는 없다")은 주석에 적혀 있었으나 강제되지
않았다.

필드의 affinity를 알아야 정확히 막을 수 있어서 move 검사기에 struct 필드
표와 바인딩의 선언 타입을 넣었다. copyable 필드는 막지 않는다 — w.label은
통과한다.

이것이 LRU에서 열거형으로 우회한 것을 사후에 정당화한다. 그때는 "struct로는
안 되고 열거형으로는 된다"가 우연처럼 보였는데, 열거형이 유일한 길인 것이
규칙이었고 struct 쪽이 새고 있었을 뿐이다.

여태 안 보인 이유: samples/05는 자원 타입을 직접 다루고 struct에 담지
않는다. 개밥 먹기에서 자원을 자료구조에 담는 코드를 처음 쓰면서 드러났다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZVDeU6KLuUVL3gs18Hm3E
2026-08-30 19:21:16 +09:00

20 KiB

개밥 먹기 발견 기록

dogfoods/를 쓰면서 나온 것들. 고친 것과 열어둔 것을 같이 적는다.


2. Atomic File Updater (2026-08-30)

목적: 동시성을 안 섞고 자원 모델만 시험한다. 임시 파일에 쓰고, 디스크에 내리고, 원자적으로 바꿔치기한다. 실패 경로가 넷이고 전부 정리가 필요하다.

산출물: std-draft/fs.cool 65줄, atomic-update/naive.cool 18줄, atomic-update/careful.cool 58줄.

D1. 상대 경로 import가 패키지로 오인됐다 — 고침

import "../std-draft/fs"가 조용히 해소되지 않았다. is_package가 첫 세그먼트에 점이 있는지만 봤는데 ..이 걸렸다.

무서운 것은 버그 자체가 아니라 그 결과다. import가 해소되지 않으면 그 모듈의 이름이 전부 불투명해지고, "모르는 것을 틀렸다고 말하지 않는다"는 원칙에 따라 검사기가 무엇이든 통과시킨다. 첫 coolc check 결과가 exit 0이었는데 아무 뜻도 없었다. 없는 메서드를 불러도 통과했다.

고친 뒤 같은 코드가 정확히 잡힌다:

capability Fs.Fs에 this_does_not_exist 메서드가 없습니다

dogfoods/README.md의 합격 기준 ②가 여기서 나왔다.

D2. 값 있는 식을 문으로 버릴 수 있었다 — 고침

fs.remove(path)     // Result가 조용히 사라진다
1

이것이 통과했다. 즉 Result를 버리는 방법이 있었다. 개밥 먹기 1차 보고서와 언어 투어에서 내가 "이 언어에는 실패를 버릴 방법이 없다"고 적었는데 틀렸다 — List.each에 Result 반환 클로저를 못 넘긴다는 좁은 사실을 언어 전체의 성질로 일반화했다.

이제 꼬리가 아닌 자리의 식이 값을 남기면 오류다:

이 식이 남기는 Result[Unit, IoError]이(가) 버려집니다
(일부러 버리려면 let _ = 로 적으십시오)

부산물이 좋다. 정리 경로에서 오류를 일부러 무시하는 관용구가 생겼고, 버린다는 사실이 코드에 보인다:

let _ = fs.close(f)      // 정리 중의 실패는 삼킨다 — 원래 오류가 더 중요하다
discard(fs, tmp, e)

D3. ?를 자원과 함께 쓸 수 없다 — 이번 개밥 먹기의 본체

naive.cool 18줄은 이렇게 생겼고 통과한다:

let f = fs.create(tmp)?
fs.write(f, contents)?      // 실패하면 f가 안 닫히고 tmp가 남는다
fs.sync(f)?
fs.close(f)?
fs.rename(tmp, path)?

?가 조기 반환하므로 정리를 건너뛴다. v0가 "오용 금지, 누수 허용"이라 검사기는 아무 말도 하지 않는다.

제대로 정리하면 careful.cool 58줄이 되고 5단 중첩 match가 된다. ?를 한 번도 못 쓴다. 같은 일에 3.2배다.

이것이 resource / with 제안이 필요한 이유의 전부다. 그리고 그 제안이 암묵적 drop보다 나은 이유도 여기서 보인다 — 정리 지점이 닫는 중괄호로 눈에 보이고, Fs.close가 effects 절에 나타나 검사된다.

D4. capability 메서드가 수신자를 소비할 수 없다 — 열림

close가 핸들을 소비해야 하는데 own은 파라미터에만 붙는다. capability 메서드에는 수신자를 적는 자리가 없다.

우회했다 — 핸들의 메서드가 아니라 Fs의 메서드로 두고 핸들을 인자로 받는다:

fn close(own f: WriteFile) effects {Fs.close} -> Result[Unit, IoError]

f.close() 대신 fs.close(f)가 된다. 읽기에 나쁘지 않고 오히려 권한 (Fs)이 필요하다는 게 보인다. 지금은 우회로 충분해 보인다.

D5. 함수 타입에 own이 없다 — 열림, 그리고 이건 구멍이다

pub fn apply(f: fn(own Handle) -> Handle, own h: Handle) -> Handle
                    ^^^ 타입이(가) 필요합니다 — own 발견

문법이 fn_type = "fn" "(" type_list ")"own이 못 들어간다. 그래서 소유권을 가져가는 클로저를 타입으로 표현할 수 없다.

결과로 이것이 통과한다:

List.fold(xs, h, fn(acc, n) { step(acc, n) })   // step은 own을 받는다

fold의 시그니처는 fn(acc, a) -> acc이고 무표기는 빌림인데, 클로저 리터럴의 파라미터에는 소유권 표시가 없어 move 검사기가 소유한 값처럼 취급한다. 타입은 "빌린다"고 말하는데 리터럴은 "가져간다"처럼 행동한다.

기본 검사는 멀쩡하다 — 빌린 값 옮기기도 이중 소비도 정확히 잡는다. 고차 경계에서만 뚫린다. Atomic Updater는 선형 코드라 안 걸렸지만, Mini Shell(파이프라인을 따라 FD를 나름)과 DB Pool(lease를 fold로 다룸)은 정면으로 걸린다.

→ 그 둘을 쓰기 전에 결정해야 한다.

D5 후속 — 고침, 그리고 std의 실수 하나가 딸려 나왔다

own을 함수 타입과 클로저 파라미터에 넣었다:

fn(own Handle) -> Handle              // 타입에 적을 수 있다
List.fold(xs, h, fn(own acc, n) {...}) // 리터럴에도 적는다

클로저 파라미터의 소유권은 리터럴이 스스로 적는다. 타입은 기대 타입에서 읽어오지만 소유권은 읽어오지 않는다 — move 검사는 타입 검사와 별도 순회라 타입을 모르고, 소유권은 타입보다 결과가 크기 때문이다.

unify가 정확히 일치를 요구한다. 빌리는 클로저를 소유 자리에 넘기는 것은 안전하지만 그 반대는 아니고, 방향을 다루려면 부분 타입이 필요한데 없다.

딸려 나온 것: std/list.coolfold가 틀려 있었다.

f: fn(acc, a) -> acc        // 전 — 빌림
f: fn(own acc, a) -> acc    // 후 — 누적자는 매 단계 소비되고 새것으로 바뀐다

빌림으로 적혀 있어서 affine 값을 fold로 실어나를 수 없었다. 그런데 그 사실이 드러나지 않았던 이유가 바로 이 구멍이었다 — 클로저 파라미터를 무조건 소유로 봤으니 아무 오류도 안 났다. 구멍이 자기가 숨긴 버그를 덮고 있었다.

남은 한계 — 제네릭을 통과해 보지 못한다

move 검사는 타입이 없어 foldacc가 호출 지점에서 무엇으로 묶이는지 모른다. 그래서 클로저 파라미터에 표기가 없고 기대 타입이 제네릭 변수면 affinity를 판정하지 못한다. 지금은 소유권 표기 불일치로 잡히지만, 표기가 양쪽 다 없으면 통과한다.

근본 해법은 move 검사가 타입을 보는 것이고, 그건 두 순회를 합치는 일이다. v0에서는 하지 않는다.

대가 하나 — own이 흔해진다

own은 흔하지 않은 쪽에 붙는 표기인데, fold가 항상 요구하면 흔해진다. Config 같은 copyable 누적자에도 own을 적게 된다. 정확히 일치를 요구한 결과이고, 부분 타입을 넣으면 사라진다. 표기의 신호가 약해지는지 지켜본다.

D6. 문자에 접근할 방법이 없다 — 열림

Stringsplit, trim, starts_with, contains뿐이다. 인덱싱도 chars도 없어 어휘 분석을 쓸 수 없다. Atomic Updater에는 파싱이 없어 안 걸렸지만 Mini Shell은 여기서 막힌다.

되돌리기 싼 std 문제다.

D7. effect-free 테스트로는 이 프로그램을 하나도 테스트할 수 없다 — 예상됨

update는 전부 effect다. 테스트는 capability를 받지 않으므로 부를 수 없다. 설계대로이고, 대가가 이제 실물로 보인다.

가짜 capability를 만들 수단이 없는 한 이 층은 테스트 밖에 있다. ※ 가짜 capability를 허용해도 보안 성질은 안 깨질 것으로 보인다 — 클로저로 만든 가짜 Fs는 진짜 권한을 갖지 않는다. 실제로 불편해진 뒤에 판단한다.

소유권 검사가 실제로 잡는 것 (확인)

두 번 닫기 잡힘f은(는) 이미 move되었습니다 (4:22에서 소비)
닫은 뒤 쓰기 잡힘 — 같은 진단
빌린 핸들을 반환 잡힘빌린 값이라 반환할 수 없습니다
소유한 핸들을 반환 통과 — 옳다. create가 그렇게 생겼다
조기 반환으로 누수 통과 — v0 정책("오용 금지, 누수 허용")

마지막 줄이 D3이고, with가 닫으려는 자리다.


6. DB Connection Pool (2026-08-30)

목적: 자원 모델의 두 번째 데이터 점. 파일에 없던 셋을 압박한다 — 자원이 두 층(풀/lease), 반납이 파괴가 아님, 획득이 실패할 수 있음.

산출물: std-draft/db.cool 50줄, db-pool/naive.cool 25줄, db-pool/careful.cool 58줄.

D8. 인자 안에서 소비와 사용을 섞을 수 없다

release_then(db, pool, l, db.query(l, sql))
                       ^                ^
                    소비              사용
l은(는) 이미 move되었습니다 (13:41에서 소비)

move 검사가 인자를 왼쪽부터 걷기 때문이다. 실제 평가 순서와 무관하다 — 사람은 "질의가 먼저 돌고 그다음 반납"이라고 읽지만 검사기는 그렇게 안 본다.

우회는 쉽다. 먼저 let으로 묶고 넘긴다. 다만 왜 안 되는지가 코드에서 안 보이므로 진단 메시지가 위치만 말하고 이유를 말하지 않는다.

두 사례 비교 — 이것이 with 결정의 근거다

naive careful 배수 match 중첩 정리 도우미
Atomic Updater 18 58 3.2 4단 2개
DB Pool 25 58 2.3 1단 2개

공통 (2/2)

  1. 자원을 들면 ?를 한 번도 못 쓴다. 예외 없이 그렇다
  2. "정리하고 결과를 그대로 실어나르는 도우미"를 양쪽이 각자 발명했다 (abandon/discard, release_then/close_then). 같은 패턴이 두 번 독립적으로 나온 것은 그것이 진짜 추상이라는 뜻이다
  3. 누수는 조용히 통과한다 — 검사기가 아무 말도 안 한다

다른 점 — 그리고 이게 중요하다

파일은 4단 중첩이고 풀은 1단이다. 이유는 정리가 단계마다 다른가이다.

  • 파일: 닫기 전이면 close + remove, 닫은 뒤면 remove만 → 단계마다 다름
  • 풀: 성공이든 실패든 언제나 release → 하나로 접힘

고통이 자원 개수가 아니라 정리의 균일성에 비례한다. with의 값도 그만큼 달라진다.

D9. with가 성립하려면 D4를 먼저 풀어야 한다

풀 사례가 with의 설계 제약 하나를 드러냈다.

with l = db.acquire(pool)? { ... }     // 나갈 때 무엇을 부르나?

반납은 db.release(pool, l)이다 — 풀과 권한이 필요하다. 그런데 with가 자동으로 부르려면 자원만으로 정리를 표현할 수 있어야 한다. 즉:

resource Lease {
    release close(own self) effects {Db.release} -> Result[Unit, DbError]
}

own self가 필요하다. 그런데 capability 메서드는 수신자를 소비할 수 없다(D4). own은 파라미터에만 붙는다.

파일 쪽도 같다 — Fs.close(own f)Fs 권한을 요구하므로 자원만으로는 정리가 안 된다.

따라서 순서가 정해진다: D4 → resource/with. 자원이 자기 정리를 스스로 선언할 수 있어야 with가 성립한다. 런타임이 lease 안에 풀을 넣어 두면 되므로 구현상의 문제는 아니지만, 언어에 표현할 자리가 없다.

잘 된 것 — 설계가 버틴 부분

  • 두 층 자원이 자연스럽게 중첩된다. 풀이 바깥, lease가 안쪽
  • 반납 ≠ 파괴가 시그니처로 표현된다. release(p: Pool, own l: Lease)
  • 획득 실패가 Result로 자연스럽다. Exhausted가 호출자에게 선택을 준다
  • List[String]List는 내장 타입이라 import가 필요 없다. 모듈 List를 가져오는 것은 그 타입의 함수를 쓸 때뿐이다 — 미사용 import lint가 정확히 이것을 지적했다

LRU 캐시 (2026-08-30)

목적: 앞의 둘이 못 건드린 데를 친다. 파일과 풀은 정리 지점이 렉시컬이었다. LRU는 아니다 — 축출은 삽입의 부작용으로 예측할 수 없는 때에 일어난다.

산출물: lru-cache/values.cool 82줄, lru-cache/resources.cool 73줄.

D10. Option/Result 패턴이 타입을 통째로 잃고 있었다 — 고침. 큰 것이었다

pub fn probe3(r: Result[Int, String]) -> String {
    match r {
        Ok(v) => v,        // Int를 String 자리에 둔다
        Err(e) => e,
    }
}

통과했다. 원인: typecheck의 env.ctors에 내장 생성자가 없었다. 그래서 None은 생성자가 아니라 None이라는 이름의 변수 바인딩이 되고, Ok(v)vTUnknown이 되어 무엇과도 맞았다.

이 언어의 핵심 오류 처리 수단인 Option/Result의 패턴 매칭이 타입을 전혀 검사하지 않고 있었다. 소진성 검사는 자기 is_ctor에 내장을 갖고 있어서 이 사실을 덮고 있었다 — 빠진 경우는 잡으면서 타입은 안 봤다.

내장 열거형을 등록해서 고쳤다. 기존 코드는 하나도 안 깨졌다.

이 버그가 여태 안 보인 이유가 씁쓸하다. match r { Ok(v) => Int.show(v) } 같은 코드는 v가 TUnknown이어도 통과하므로 아무도 이상함을 못 느낀다. 틀린 코드를 써봐야 드러난다.

D11. struct 필드와 affine 값 — 그리고 여기서 보안 구멍이 나왔다

처음 이렇게 적었다: "필드 접근은 빌림이라 affine 값을 꺼낼 수 없다." 규칙으로는 맞았는데 구현이 그것을 강제하지 않고 있었다.

나중에 "다 고쳤나"를 확인하려고 전부 다시 돌려보다 드러났다:

pub capability Pay { fn charge(n: Int) effects {Pay.charge} }
pub struct Wrapper { pay: Pay }
pub fn consume(own p: Pay) effects {Pay.charge}

pub fn duplicate(w: Wrapper) effects {Pay.charge} {
    consume(w.pay)
    consume(w.pay)      // 같은 capability를 두 번 소비한다
}

통과했다. 그것도 w빌린 값인데도. 즉 보안 정리 (ii)("safe code에서 capability는 복제·위조되지 않는다")가 깨져 있었다. 이 세션에서 찾은 것 중 가장 심각하다.

원인: E_field가 빌린 값(v_use = true)을 돌려주는데, Move 문맥에서 그것을 검사하는 곳이 E_ident 분기에만 있었다. 필드 접근은 그 분기를 지나가지 않는다.

고쳤다. E_field가 Move 자리에 놓이고 필드가 affine이면 오류다:

pay 필드는 affine이라 다른 함수에 넘길 수 없습니다
(v0에는 부분 move가 없습니다 — 꺼내려면 열거형으로 감싸십시오)

필드의 affinity를 알려면 바인딩의 선언 타입이 필요해서, move 검사기에 struct 필드 표와 바인딩 타입을 넣었다. copyable 필드는 막지 않는다w.label은 통과한다.

그리고 이것이 LRU에서 열거형으로 우회한 것을 사후에 정당화한다. 그때는 "struct로는 안 되고 열거형으로는 된다"가 우연처럼 보였는데, 열거형이 유일한 길인 것이 규칙이었고 struct 쪽이 새고 있었을 뿐이다.

이 버그가 여태 안 보인 이유

samples/05_move_errors.cool은 자원 타입을 직접 다룬다. struct에 넣고 필드로 꺼내는 코드가 없었다. 개밥 먹기에서 자원을 자료구조에 담는 코드를 처음 쓰면서 드러났다.

(원래 기록) 열거형으로만 둘을 함께 돌려줄 수 있다

put은 캐시와 축출된 자원을 함께 돌려줘야 한다. 튜플이 없으니 struct다.

pub struct Put { cache: LeaseCache, evicted: Option[Db.Lease] }
...
match p.evicted { Some(l) => release(l), ... }
l은(는) 빌린 값이라 다른 함수에 넘길 수 없습니다

필드 접근은 빌림이고, struct를 분해하는 패턴이 언어에 없다. 그래서 struct에 넣은 affine 값은 다시 꺼낼 수 없다.

열거형은 된다 — 패턴이 분해하기 때문이다:

pub enum Put {
    Kept(LeaseCache),
    Evicted(LeaseCache, Db.Lease),
}
match p { Evicted(c, l) => release_then(db, pool, l, c), ... }

즉 "둘을 함께 돌려주기"가 열거형으로만 가능하다. 튜플이 없는 대가가 여기서 두 번째로 나온다(F5에 이어). 그리고 이건 취향 문제가 아니라 표현 가능성 문제다.

→ 해법 후보: struct 분해 패턴 + 부분 이동, 또는 튜플. 둘 다 문법 결정이다.

D12. 축출은 렉시컬이 아니다 — with가 못 닿는 첫 자리

with는 렉시컬 수명만 다룬다. 축출된 lease는 어느 블록에도 묶이지 않는다. 삽입할 때 튀어나오고, 그 시점은 캐시 상태에 달려 있다.

그래서 resource가 렉시컬 전용이면 자원을 담는 컨테이너를 언어로 만들 수 없다. 그리고 이것이 이미 우리 설계에 나타나 있었다 — Pool 자체가 자원 캐시인데, std-draft/db.cool에서 런타임이 주는 불투명한 capability로 선언했다. 만들 수 없어서 그렇게 한 것이다.

두 갈래다:

  • (a) 자원은 렉시컬 전용 → 자원 컨테이너는 런타임만 만들 수 있다. 풀, 캐시, 레지스트리가 전부 언어 밖이 된다. 단순하고, 지금 상태가 그렇다
  • (b) 자원이 자료구조로 탈출할 수 있다with만으로 부족하고 결국 선형 타입이 필요하다. v0가 미룬 바로 그것

(a)를 권한다. 자원 컨테이너는 드물고, 만드는 쪽은 런타임이며, 쓰는 쪽은 lease를 렉시컬하게 빌린다. 다만 이 선택이 무엇을 포기하는지 적어둬야 한다 — 사용자가 자기 자원 풀을 언어로 못 만든다.

자료구조 자체의 마찰 (값 판)

  • Map이 없어 O(n)이다. LRU의 요점이 O(1)인데 표현할 수단이 없다
  • 튜플이 없어 운반용 struct를 세 번 만들었다Got, Put, Dropping
  • get이 캐시를 새로 돌려줘야 한다. 최근성이 바뀌므로. 공유 가변 상태가 없다는 것의 대가이고, 호출자가 캐시를 계속 실어날라야 한다
  • 뒤에서 자르는 함수가 std에 없다. drop_lastreverse 두 번으로 썼다
  • fold에 "첫 원소 건너뛰기"가 없어 Dropping struct를 또 만들었다

결정 — resource / with

넣는다. 다만 D4가 먼저다.

근거는 위 표다. 데이터 두 점에서 공통으로:

  • ?를 자원과 함께 쓸 수 없다 (2/2)
  • 같은 정리 패턴을 각자 발명했다 (2/2)
  • 누수가 조용히 통과한다 (2/2)
  • 2.3 ~ 3.2배

그리고 with가 암묵적 drop보다 나은 이유가 여기서도 확인된다 — 정리 지점이 닫는 중괄호로 보이고, 정리의 effect가 effects 절에 나타나 검사된다.

순서

  1. D4 — 수신자를 소비하는 메서드. 자원이 자기 정리를 스스로 선언할 수 있어야 한다
  2. resource 종류와 with — 렉시컬 수명, 정리 강제, 전이 규칙 (자원을 필드로 가진 타입은 전이적으로 자원)
  3. 그 뒤 Mini Shell로 세 번째 데이터 점

LRU가 더한 제약: 자원은 렉시컬 전용으로 간다. 자원을 담는 컨테이너는 런타임만 만들 수 있다 — 축출처럼 비렉시컬한 정리는 with가 못 닿기 때문이다. 사용자가 자기 자원 풀을 언어로 못 만드는 것이 그 대가다.

미루는 것: crash 시 정리 여부. with를 넣어도 이 질문은 열려 있다 — 정상 종료·return·?에서는 돌고 crash에서는 안 도는 것이 지금의 잠정 답이지만, 감독(supervision)이 들어오면 다시 봐야 한다.