---
name: ocaml-coding
description: "masc 저장소에서 OCaml 5.5 + Eio 코드를 쓰기 전에 보는 점검표다. 오류를 Result 와 닫힌 합타입으로 다루는 법, 예외 잡기와 Eio 취소, executor의 실제 worker 정리와 실패 재실행 금지, Mutex·Lazy 선택, 자원 정리(Switch.on_release 와 Fun.protect), 막히는 I/O, Task Skill 새 해석과 기존 선택의 준비 쌍 연결, .mli 먼저 고치기, 결정론 게이트와 CI lint 가 새 줄에서 잡는 모양을 다룬다. lib/·bin/·test/ 의 .ml/.mli 를 새로 쓰거나 고칠 때 쓴다."
---

# masc OCaml 점검표

이 저장소는 OCaml 5.5 에 고정돼 있다(`dune-project` 의 `(ocaml (= 5.5.1))`).
표준 라이브러리와 Eio 의 사실은 공식 인터페이스로 확인한다.

- OCaml 매뉴얼: https://ocaml.org/manual/5.5/index.html
- API: `https://ocaml.org/manual/5.5/api/<Module>.html`
- Eio: opam 스위치의 `lib/eio/*.mli`, 또는 https://ocaml.org/p/eio/latest/doc/Eio/index.html

규칙의 원본은 `docs/constitution.xml` 이다. 이 문서와 헌법이 다르면 헌법이 맞다.

## 1. 상태와 오류는 닫힌 합타입

- 서로 배타적인 상태·판정·오류는 variant 로 쓴다. 문자열이나 부분 문자열을 비교해 다음 동작을
  고르지 않는다(헌법 `string_matching`). `String.starts_with ~prefix:"..."` 로 분기하고 싶어지면
  그 값을 만드는 쪽에 생성자를 하나 더 둔다.
- 파서는 모르는 입력에 `None` 이나 `Error` 를 준다. 편한 기본값으로 눌러 담지 않는다.
- `match` 는 경우를 다 적는다. 새 생성자를 넣었을 때 컴파일러가 빠진 자리를 알려 주게 한다.
  상태 전이표에 `| _ -> false` 를 두면 새 상태의 전이가 빠져도 컴파일된다.
- 실패할 수 있는 계산은 `Result` 로 돌려준다.

  ```ocaml
  let ( let* ) = Result.bind

  let process input =
    let* parsed = parse input in
    let* computed = compute parsed in
    Ok (render computed)
  ```

- `failwith "not implemented"` 같은 자리 채움을 두지 않는다. 컴파일은 되고 실행하면 터진다.
- `assert false` 는 타입으로 막을 수 없는, 정말 도달하지 않는 가지에만 둔다.
- `option` 의 `None` 이 무엇을 뜻하는지 `.mli` 문서 주석에 적는다.

## 2. 예외 잡기와 Eio 취소

Eio 는 취소를 `Eio.Cancel.Cancelled` 예외로 전한다. 모든 예외를 잡는 가지는 이 취소까지 삼킨다.
삼킨 fiber 는 멈추라는 요청을 받고도 계속 돈다.

- 잡을 예외의 생성자를 적는다.

  ```ocaml
  match Eio.Path.load path with
  | contents -> Ok contents
  | exception (Eio.Io _ as ex) -> Error (Format.asprintf "%a" Eio.Exn.pp ex)
  ```

- 전부 잡아야 하면 취소를 먼저 다시 던진다.

  ```ocaml
  match run () with
  | value -> Ok value
  | exception (Eio.Cancel.Cancelled _ as ex) -> raise ex
  | exception ex -> Error (Printexc.to_string ex)
  ```

## 3. 변경 검증

- 제공받은 switch로 자동 복구 fiber를 예약할 때는 discovery 전과 실제 예약 직전에
  `Eio.Switch.check`를 한다. `Cancel.protect`도 취소된 switch를 다시 켜지 않는다.
  `fork`가 callback 없이 반환하면 active 예약만 남을 수 있다. 취소를 저장소/저널 오류나
  정상 접수로 바꾸지 않고, 다음 정상 scope가 원래 durable owner를 다시 읽을 수 있게 한다.

- 원래 owned-job 결과가 durable해진 뒤의 Composition 알림은 controller의
  `notify_result_ready`로 요청한다. 알림 도착을 intake·Task 재개·효과 허가로 기록하지 않는다.
  첫 조회 전 알림은 공유하고 조회 중 알림은 후속 fresh pulse로 남긴다. 알림 worker의
  identity·취소 정리를 지키고 supervisor 수명의 같은 switch를 쓴다. 직접 maintenance
  pulse와 구분하며 timer·재시도 간격·결과/프로필 cache를 새로 만들지 않는다.

소스 패턴이나 임의 개수 상한으로 구현을 제한하지 않는다. 타입 검사와 실제 동작 테스트로 변경을 검증하고, 로컬 빌드와 CI 실행 시점은 헌법의 실행 프로토콜을 따른다.

## 4. Mutex 고르기

Eio 인터페이스(`eio_mutex.mli`)가 말하는 차이: `Stdlib.Mutex` 는 기다리는 동안 도메인 전체를 막고,
`Eio.Mutex` 는 기다리는 동안 다른 fiber 를 돌린다.

| 상황 | 선택 |
|---|---|
| 잠근 구간에서 I/O 를 하거나 fiber 가 바뀔 수 있다 | `Eio.Mutex` |
| 잠근 구간이 짧고 fiber 가 바뀌지 않는다 | `Stdlib.Mutex` |
| 모듈 초기화처럼 Eio 문맥 밖에서 잡힌다 | `Stdlib.Mutex` |
| `Eio_main.run` 없이 테스트에서 불린다 | `Stdlib.Mutex` |

- `Eio.Mutex.use_rw ~protect t fn`: `fn` 이 예외를 내면 mutex 가 못 쓰게 된다(`Poisoned`).
  `~protect:true` 는 잠근 구간을 취소에서 보호한다. 잠금을 기다리는 동안은 보호하지 않는다.
- `Eio.Mutex.use_ro t fn`: 읽기만 할 때. 예외가 나도 풀고 넘어간다.
- 한 도메인 안에서만 도는 코드는 막히는 연산을 하기 전에는 fiber 가 바뀌지 않는다. mutex 가 필요 없을 수 있다.
- mutex 로 감싸는 방식을 바꾸면 `rg -n '<함수 이름>' test/` 로 테스트 호출을 전부 센다.
  `Eio_main.run` 밖에서 부르는 테스트가 있으면 같이 고친다.

## 5. Lazy 고르기

- `Stdlib.Lazy.force` 는 동시에 안전하지 않다. 여러 fiber·systhread·도메인이 한 값을 동시에
  force 하면 `Lazy.Undefined` 가 날 수 있다(`lazy.mli`).
- Eio fiber 여럿이 force 하는 값은 `Eio.Lazy.from_fun ~cancel fn`.
  - `` `Restart ``: force 하던 fiber 가 취소되면 기다리던 다음 fiber 가 `fn` 을 다시 돌린다.
  - `` `Record ``: 취소를 기록하고 그 뒤로는 계속 취소로 답한다.
  - `` `Protect ``: force 하는 동안 취소를 막는다.
- 여러 도메인·systhread 가 쓰지만 Eio 밖인 값은 `Lazy.Mutexed`(5.5 부터). 내부에서 `Stdlib.Mutex` 로
  기다리므로 Eio fiber 에서 쓰면 도메인을 막는다.
- 테스트나 모듈 초기화에서만 force 되는 값은 `Stdlib.Lazy` 로 두고, 왜 괜찮은지 주석을 단다.

## 6. 자원 정리

```ocaml
Eio.Switch.run @@ fun sw ->
let flow = open_flow ~sw in
Eio.Switch.on_release sw (fun () -> Eio.Flow.close flow);
use flow
```

- `Switch.on_release` 의 정리 함수는 `Cancel.protect` 안에서 돈다. 스위치가 이미 취소됐어도
  정리가 끊기지 않는다. 등록 역순으로 하나씩 돌고, 정리 함수의 예외는 스위치 실패로 넘어간다.
- `Fun.protect ~finally work`: `finally` 가 예외를 내면 `Fun.Finally_raised` 로 바뀌고 `work` 의 원래
  예외는 사라진다(`fun.mli`). 취소된 fiber 의 `finally` 에서 취소될 수 있는 Eio 연산을 부르면 그 연산이
  다시 `Cancelled` 를 내고, 원래 오류가 묻힌다.
- 막히거나 fiber를 바꾸는 정리는 `Switch.on_release`로 옮긴다. 순수 동기 정리(`close_in ic` 등)만 `Fun.protect`에 둔다.

## 7. 막히는 I/O 와 무거운 계산

Eio 는 협력형 스케줄러다. 취소와 시간 초과는 fiber 가 멈추는 지점에서만 먹힌다.

- `Eio.Time.with_timeout clock d fn` 은 `fn` 을 `d` 초 뒤 취소한다. `fn` 이 `Sys.readdir`,
  `Unix.read`, CPU 루프처럼 Eio 로 양보하지 않으면 끝날 때까지 안 끊긴다.
- Keeper 런타임과 제품 흐름에는 없어도 되는 시간 초과를 두지 않는다(헌법 `budget_gate`).
  시간 초과를 넣기 전에 그 대기가 끝나지 않는 경우가 실제로 무엇인지부터 적는다.
- 막히는 표준 라이브러리 호출: `Eio_unix.run_in_systhread (fun () -> ...)`
- 파일: `Eio.Path` (Eio 로 양보한다)
- 무거운 순수 계산: `Domain_pool_ref.submit_cpu_or_inline (fun () -> ...)`
- MASC의 실제 pool 제출은 `Domain_pool.submit_io`·`submit_cpu` 또는
  `Executor_pool_ref.submit_*`를 사용한다. 공통 제출 구현은 caller 취소를 실제
  worker에 전달하고 진입한 작업의 정리를 기다린다. raw `Eio.Executor_pool`의
  결과 대기 취소만으로 worker가 끝났다고 판단하지 않는다.
- worker가 effect 이후 예외를 던졌으면 그 함수를 caller에서 다시 실행하지
  않는다. inline 실행은 pool 부재·Eio 밖·기존 worker 정책이 정하는 진입 경로다.
  비동기 제출의 소유 Switch 취소와 그 promise를 관측하던 fiber의 취소도 구분한다.
- HTTP 핸들러 안에서 무거운 계산을 직접 하지 않는다. 핸들러가 멈추면 같은 도메인의 다른 요청도 멈춘다.
  대시보드 스냅샷처럼 뒤에서 미리 계산해 두고 핸들러는 마지막 값만 읽게 한다. 계산 실패는 삼키지
  않고 알린 뒤 마지막 값을 유지한다.

  ```ocaml
  let start_refresh ~sw ~clock ~interval ~compute ~on_error latest =
    Eio.Fiber.fork ~sw (fun () ->
        let rec loop () =
          (match compute () with
           | Ok snapshot -> latest := Some snapshot
           | Error err -> on_error err);
          Eio.Time.sleep clock interval;
          loop ()
        in
        loop ())
  ```

  `compute` 가 예외 대신 `Result` 를 돌려주게 만든다. 여기서 `try ... with _ -> ()` 로 감싸면
  계산 실패도 취소도 조용히 사라진다(2절).

## 8. API 를 바꿀 때는 .mli 먼저

- `Keeper_tool_plan`은 생성자의 canonicalization·중복 ID/이름·compiled operand 확인 후
  원래 노드/계약을 plan 안의 불변 index로 결속한다. 검증 단계와 첫 오류 선택은 원래
  목록 순서를 지키고, 결속도 첫 unknown/off-surface 오류에서 멈춘다. 실행 조회는 보관한
  원래 계약을 쓴다. parameter 바인딩·재구성은
  새 plan과 index를 만들며, 이전 plan/run의 출력은 새 입력에 넘기지 않는다. 실패한
  생성자의 선행 결속 비용도 Before/After에 남기고, lookup 수를 실행 시간으로 환산하지 않는다.

- Composition 파일 준비의 노드·계약·선행조건은 원래 plan 안의 불변 index를 재사용한다.
  `prepare_inputs`의 원래 노드 순서와 ID를 함께 확인하고, capability 조회는 typed ID를
  찾은 뒤에도 원래 노드의 물리 identity와 입력을 검사한다. Ready binding은 원래 멤버를
  확인한 뒤 보관한 계약을 쓰며, 동적 입력의 실제 해석·검증과 consumer의 owner·경로 검사는
  유지한다. 충돌·실패·해제 순서를 map 순서로 바꾸거나 이 lookup 감소를 실행 시간으로
  환산하지 않는다. index는 해당 plan의 수명에만 속하며 재개·재구성한 plan에 넘기지 않는다.
  Ready producer가 성공한 준비에서 만든 불변 의존 계층은 같은 원래 plan인지 물리 확인한
  executor가 재사용한다. 일반 plan의 `dependency_layers` 호출 계약은 바꾸지 않는다.
  보관한 그래프를 동적 입력·binding·결과·실행 허가로 쓰거나 다른 plan에 넘기지 않는다.
  executor의 descriptor·준비된 파일·Ready 순서는 닫힌 타입으로 구분한다. Ready의 원래
  병렬 정적 그룹을 만들려고 빈 자원 선행조건 표나 pending 전체 탐색을 만들지 않는다.
  일반 descriptor 스케줄로 대신 보내면 공유가 달라진다. 실제 binding의 충돌·배타 실행과
  Require_success 선행 settlement의 실패/ticket 반영은 Ready runner에 그대로 둔다.
  실제 준비된 파일의 선행조건 목록은 실행별 typed-ID index에서 원래 목록 그대로 찾는다.
  정적 wave의 pending-ID 집합은 원래 계층에서 만들고 ready partition만 제거한다.
  노드·그룹·선행 결과를 순회하는 원래 목록을 집합/인덱스 순서로 대체하지 않으며,
  이 자료구조의 생성·갱신 비용과 준비 후 거절된 실행도 Before/After 분모에 남긴다.
  Ready 실행의 선행 binding 순회는 같은 실행의 원래 위상 목록을 직접 방문한다.
  typed ID로 보관한 suffix의 물리 비교는 그 불변 목록 안의 정지 위치만 뜻한다.
  선언 순서의 index로 대체하거나 다른 목록·실행에 넘기지 않고, 실제 선행 binding 대기와
  resource 분류는 모두 유지한다. prefix 할당 감소와 새 index의 선행 비용을 함께 기록한다.

- Keeper 실행 setup·effective surface·Composition 재개는 `(selection, preparation)`을 함께 사용한다.
  기존 선택은 `Keeper_task_skill_turn.bind_surface`로 검증하고, 새 exact 참조는
  `resolve_surface`에서 해석과 준비를 함께 받는다. fresh 결과를 곧바로 다시 바인딩하지 않는다.
  이미 검증한 불변 입력의 준비를 바로 다시 검사하거나 optional 필드에서 추정해 꺼내지 않는다.
  정확한 snapshot/descriptor 객체와 선택 authority 검증, 오류·Task 출처·retained-surface 관측을
  유지한다. 표시용 `prepare_surface`는 Task 참조를 재해석하지 않는 별도 관측 경계다.
  검사 호출 수의 감소를 준비 시간·모델 왕복·실제 실행 개선으로 바꾸어 말하지 않는다.
  Task별 exact surface는 한 호출 안에서 원래 선택 행을 한 번 판정해 관측된 Task 소유자에게
  나눈다. 빈 Task도 유지하고, 관측되지 않은 소유자나 뒤늦게 관측한 Task가 frozen 선택을
  빌리지 않도록 한다. 공유 availability/실패 설명은 불변 결과이며 실행 허가나 전역 캐시가 아니다.
  exact 참조를 합칠 때는 identity와 content revision을 함께 구분하는 `Skill_reference.Exact_map`을
  쓴다. 첫 원래 행과 Task 출처를 유지하고, identity만 같다는 이유로 다른 pin을 합치지 않는다.
  반복 pin의 Task ID는 불변 집합에 누적해 정렬된 목록을 마지막에 한 번 만든다. 유일한 행은
  원래 객체를 유지하고, 중간 합집합을 외부로 노출하거나 전역 캐시로 넘기지 않는다.

- `.mli` 가 있으면 `.mli` 를 먼저 고치고 `.ml` 을 맞춘다. `.ml` 에만 인자를 더하면 인터페이스와
  안 맞아 컴파일이 실패한다.
- 레코드 필드를 늘리면 그 레코드를 만드는 자리를 `rg -c` 로 전부 세고 모두 고친다.
  `type t = Other.t = { ... }` 로 다시 적은 `.ml`/`.mli` 짝도 같이 고친다.
- 선택 인자 뒤에 위치 인자가 없으면 `()` 를 받는다. 없으면 경고 16(지울 수 없는 선택 인자)이다.

  ```ocaml
  let submit ~title ?(refs = []) () = ...
  ```

- 루트 `dune` 이 모든 프로파일에 `-w +4+32+69 -warn-error +a` 를 건다. 합타입의 포괄 분기(4), 안 쓰는 값(32)과 안 읽는
  레코드 필드(69)도 오류다. 값이나 필드의 마지막 사용처를 지웠으면 그 값·필드도 같은 커밋에서 지운다.

## 9. 표준 라이브러리 — 있을 거라고 착각하기 쉬운 것

다른 언어의 짝을 떠올려 호출을 지어내기 쉽다. 헷갈리면 5.5 API 문서를 먼저 연다.

| 호출 | 5.5 에서 | 비고 |
|---|---|---|
| `Unix.unsetenv` | 있다 (5.5 부터) | 이 저장소 테스트가 이미 쓴다 |
| `Sys.unsetenv` | 없다 | `Unix.unsetenv` 를 쓴다 |
| `Unix.putenv k ""` | 있다 | 지우는 게 아니다. `Sys.getenv_opt k` 는 `Some ""` 을 준다 |
| `List.take` / `List.drop` | 있다 (5.3 부터) | 음수면 `Invalid_argument` |
| `String.starts_with ~prefix` | 있다 (4.13 부터) | 분기 판단에 쓰지 않는다(1절) |
| `Option.get_or` | 없다 | `match` 로 쓴다(3절) |
| `Lazy.Mutexed` | 있다 (5.5 부터) | 5절 |

같은 함수에서 "Unbound value" 가 두 번 나오면 그 함수가 있는지부터 의심하고 인터페이스를 연다.

## 쓰고 나서 훑어보기

```sh
rg -n 'with _ ->|\| exception _ ->|with [a-z_]+ ->' <file>   # 2절
rg -n 'Option\.value.*~default:|\| *_ *-> *Some' <file>       # 3절
rg -n 'Fun\.protect' <file>                                    # 6절: finally 안에 Eio 연산이 있나
rg -n 'Stdlib\.Lazy|lazy \(' <file>                            # 5절: 여러 fiber 가 force 하나
rg -n 'failwith|assert false' <file>                           # 1절
rg -n 'Sys\.readdir|Unix\.(read|write|sleep)' <file>           # 7절: Eio 문맥에서 막히나
```
