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

456 lines
20 KiB
Markdown

# 개밥 먹기 발견 기록
`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. 값 있는 식을 문으로 버릴 수 있었다 — 고침
```cool
fs.remove(path) // Result가 조용히 사라진다
1
```
이것이 통과했다. 즉 **`Result`를 버리는 방법이 있었다.** 개밥 먹기 1차
보고서와 언어 투어에서 내가 "이 언어에는 실패를 버릴 방법이 없다"고 적었는데
틀렸다 — `List.each`에 Result 반환 클로저를 못 넘긴다는 좁은 사실을 언어 전체의
성질로 일반화했다.
이제 꼬리가 아닌 자리의 식이 값을 남기면 오류다:
```
이 식이 남기는 Result[Unit, IoError]이(가) 버려집니다
(일부러 버리려면 let _ = 로 적으십시오)
```
부산물이 좋다. 정리 경로에서 오류를 **일부러** 무시하는 관용구가 생겼고,
버린다는 사실이 코드에 보인다:
```cool
let _ = fs.close(f) // 정리 중의 실패는 삼킨다 — 원래 오류가 더 중요하다
discard(fs, tmp, e)
```
### D3. `?`를 자원과 함께 쓸 수 없다 — 이번 개밥 먹기의 본체
`naive.cool` 18줄은 이렇게 생겼고 **통과한다**:
```cool
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`의 메서드로 두고 핸들을 인자로 받는다:
```cool
fn close(own f: WriteFile) effects {Fs.close} -> Result[Unit, IoError]
```
`f.close()` 대신 `fs.close(f)`가 된다. 읽기에 나쁘지 않고 오히려 권한
(`Fs`)이 필요하다는 게 보인다. **지금은 우회로 충분해 보인다.**
### D5. 함수 타입에 `own`이 없다 — 열림, 그리고 이건 구멍이다
```cool
pub fn apply(f: fn(own Handle) -> Handle, own h: Handle) -> Handle
^^^ 타입이(가) 필요합니다 — own 발견
```
문법이 `fn_type = "fn" "(" type_list ")"``own`이 못 들어간다. 그래서
**소유권을 가져가는 클로저를 타입으로 표현할 수 없다.**
결과로 이것이 통과한다:
```cool
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`을 함수 타입과 클로저 파라미터에 넣었다:
```cool
fn(own Handle) -> Handle // 타입에 적을 수 있다
List.fold(xs, h, fn(own acc, n) {...}) // 리터럴에도 적는다
```
클로저 파라미터의 소유권은 **리터럴이 스스로 적는다.** 타입은 기대 타입에서
읽어오지만 소유권은 읽어오지 않는다 — move 검사는 타입 검사와 별도 순회라
타입을 모르고, 소유권은 타입보다 결과가 크기 때문이다.
`unify`가 정확히 일치를 요구한다. 빌리는 클로저를 소유 자리에 넘기는 것은
안전하지만 그 반대는 아니고, 방향을 다루려면 부분 타입이 필요한데 없다.
**딸려 나온 것**: `std/list.cool``fold`가 틀려 있었다.
```cool
f: fn(acc, a) -> acc // 전 — 빌림
f: fn(own acc, a) -> acc // 후 — 누적자는 매 단계 소비되고 새것으로 바뀐다
```
빌림으로 적혀 있어서 **affine 값을 fold로 실어나를 수 없었다.** 그런데 그
사실이 드러나지 않았던 이유가 바로 이 구멍이었다 — 클로저 파라미터를 무조건
소유로 봤으니 아무 오류도 안 났다. **구멍이 자기가 숨긴 버그를 덮고 있었다.**
#### 남은 한계 — 제네릭을 통과해 보지 못한다
move 검사는 타입이 없어 `fold``acc`가 호출 지점에서 무엇으로 묶이는지
모른다. 그래서 클로저 파라미터에 표기가 없고 기대 타입이 제네릭 변수면
affinity를 판정하지 못한다. 지금은 소유권 표기 불일치로 잡히지만, 표기가
양쪽 다 없으면 통과한다.
근본 해법은 move 검사가 타입을 보는 것이고, 그건 두 순회를 합치는 일이다.
v0에서는 하지 않는다.
#### 대가 하나 — own이 흔해진다
`own`은 흔하지 않은 쪽에 붙는 표기인데, `fold`가 항상 요구하면 흔해진다.
`Config` 같은 copyable 누적자에도 `own`을 적게 된다. 정확히 일치를 요구한
결과이고, 부분 타입을 넣으면 사라진다. **표기의 신호가 약해지는지 지켜본다.**
### D6. 문자에 접근할 방법이 없다 — 열림
`String``split`, `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. 인자 안에서 소비와 사용을 섞을 수 없다
```cool
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`의 설계 제약 하나를 드러냈다.
```cool
with l = db.acquire(pool)? { ... } // 나갈 때 무엇을 부르나?
```
반납은 `db.release(pool, l)`이다 — **풀과 권한이 필요하다.** 그런데 `with`
자동으로 부르려면 **자원만으로 정리를 표현할 수 있어야 한다.** 즉:
```cool
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 패턴이 타입을 통째로 잃고 있었다 — 고침. 큰 것이었다
```cool
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)``v``TUnknown`이 되어 무엇과도 맞았다.
즉 **이 언어의 핵심 오류 처리 수단인 Option/Result의 패턴 매칭이 타입을 전혀
검사하지 않고 있었다.** 소진성 검사는 자기 `is_ctor`에 내장을 갖고 있어서
이 사실을 덮고 있었다 — 빠진 경우는 잡으면서 타입은 안 봤다.
내장 열거형을 등록해서 고쳤다. 기존 코드는 하나도 안 깨졌다.
이 버그가 여태 안 보인 이유가 씁쓸하다. `match r { Ok(v) => Int.show(v) }`
같은 코드는 v가 TUnknown이어도 통과하므로 **아무도 이상함을 못 느낀다.**
틀린 코드를 써봐야 드러난다.
### D11. struct 필드와 affine 값 — 그리고 여기서 보안 구멍이 나왔다
처음 이렇게 적었다: "필드 접근은 빌림이라 affine 값을 꺼낼 수 없다."
**규칙으로는 맞았는데 구현이 그것을 강제하지 않고 있었다.**
나중에 "다 고쳤나"를 확인하려고 전부 다시 돌려보다 드러났다:
```cool
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다.
```cool
pub struct Put { cache: LeaseCache, evicted: Option[Db.Lease] }
...
match p.evicted { Some(l) => release(l), ... }
```
```
l은(는) 빌린 값이라 다른 함수에 넘길 수 없습니다
```
**필드 접근은 빌림이고, struct를 분해하는 패턴이 언어에 없다.** 그래서 struct에
넣은 affine 값은 다시 꺼낼 수 없다.
열거형은 된다 — 패턴이 분해하기 때문이다:
```cool
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_last``reverse` 두 번으로 썼다
- **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)이 들어오면 다시 봐야 한다.