Notes
子プロセス用 pipe は exec 前に不要 FD を閉じる
CLI ラッパーから別プロセスへ prompt を stdin で渡す処理が、子プロセスの終了待ちで必ず timeout した。終了監視には waitpid を使っていたが、原因は監視ではなく pipe の file descriptor(FD)継承だった。
親が prompt を書き終えて stdin の書き込み側 FD を閉じても、子が同じ書き込み側 FD を継承していると EOF が届かない。pipe は書き込み側 FD が 1 つでも開いている間、読み込み側へ「これ以上データが来ない」と通知できないためだ。
親: stdin_write を閉じる
子: stdin_write をまだ保持している
↓
子が stdin の EOF を待ち続ける
↓
子が終了しない
↓
waitpid は正常に「未終了」と判定する
OCaml の Unix.pipe は、cloexec を省略すると歴史的理由で keep-on-exec になる。公式ドキュメントでも安全なデフォルトではないため明示指定が推奨されている。
let stdin_read, stdin_write = Unix.pipe ~cloexec:true () in
let stdout_read, stdout_write = Unix.pipe ~cloexec:true () in
let stderr_read, stderr_write = Unix.pipe ~cloexec:true () in
close-on-exec にすると、fork 後に別プログラムへ exec する時点で不要な FD が閉じられる。親が prompt を書き終えて閉じれば、子へ EOF が届いて正常終了できる。
回帰テストでは、stdin を EOF まで読む偽 CLI を使う。stdin を読まない偽 CLI では FD リークを再現できない。
cat >/dev/null
printf '%s\n' '{"summary":"Summary","items":[]}'
終了待ちが timeout したとき、waitpid の置き換えやプロセス名ポーリングを足す前に、子が終了可能な FD 状態で起動されているかを確認する。
参考: OCaml Unix API
lefthookでbun installの実行チェックをpre-commitに入れる
bun.lock が更新されたのに bun install を実行していないままコミットしようとすると、CIや他の開発者の環境で依存関係がズレる。lefthook の pre-commit に依存関係チェックを入れることで、このミスをコミット前に防ぐ。
Before: bun install を忘れてコミットしても気づけない。
After: node_modules/.bin のタイムスタンプと bun.lock を比較し、古ければコミットをブロックする。
# lefthook.yml
assert_lefthook_installed: true
pre-commit:
commands:
deps-check:
run: |
if [ ! -d node_modules/.bin ] || [ bun.lock -nt node_modules/.bin ]; then
echo "node_modules が古いです。bun install を実行してください。"
exit 1
fi
設計のポイント:
assert_lefthook_installed: true— lefthook バイナリが見つからない場合に hook 実行自体を exit 1 で失敗させる公式オプション。新規参加者がインストールを忘れても気づけるようになるnode_modules/.binをタイムスタンプ比較の対象にする —bun installはパッケージに変更がない場合でも.binのタイムスタンプを更新する(公式ドキュメントに明記はないが実装の観察値)。一方node_modulesディレクトリ直下は変更がなければタイムスタンプが変わらないことがあり、「install 済みなのに古いと判定される」フォールスポジティブが起きた。.binを対象にすることでこの問題を回避できる-nt(newer than)演算子 — bash の[[ A -nt B ]]で A が B より新しいか判定できる。bun.lock -nt node_modules/.binは「lockfileの方が新しい=install未実行」を意味する
lefthook の setup フックは staged ファイルがない状態では skip されることがあるため、commands の先頭に deps-check を入れる方が確実にブロックできた。
Tailwind Typography の prose-code: は pre 内の code にも適用される
Tailwind Typography で prose-code:text-[var(--color-text)] のようなカスタム色を設定すると、インラインコード(`code`)だけでなく、コードブロック(```...```)内の <code> にも同じ色が適用される。
コードブロックの背景は Tailwind Typography のデフォルトで暗い色(slate-800 相当)になっているため、そこに濃い文字色を上書きすると「暗い背景に暗い文字」になってコードが読めなくなる。
<!-- この class は pre > code にも効いてしまう -->
<div class="prose prose-code:text-[var(--color-text)]">
<!-- インラインコード -->
<p>Use <code>useState</code> here.</p>
<!-- コードブロック(背景が暗いのに文字も暗くなる)-->
<pre><code>const x = 1;</code></pre>
</div>
修正は pre 内の code だけを明示的に上書きする:
<!-- [&_pre_code]: でスコープを絞る -->
<div class="prose prose-code:text-[var(--color-text)] [&_pre_code]:text-slate-100">
または、インラインコードだけに色を当てたい場合は :not(pre) で絞る:
<div class="prose [&_:not(pre)_code]:text-[var(--color-text)]">
prose-code: はすべての code 要素を対象にするセレクタで、Tailwind Typography が内部で生成する --tw-prose-code 変数を上書きする。この変数は pre > code にも引き継がれるため、意図せずコードブロックにも効いてしまう。インラインコードとコードブロックの色を分けたい場合は、最初から :not(pre) のアービトラリーセレクタで限定する方が安全。
progenitor で OpenAPI spec から Rust クライアントをコンパイル時生成
Rust CLI から HTTP API を呼び出すとき、手書きの reqwest クライアントではなく progenitor crate でコンパイル時にクライアントコードを自動生成する方法を試した。
OpenAPI の JSON ファイルをリポジトリに保存しておき、build.rs から progenitor を呼ぶと、ビルド時に型付きクライアントが生成される。サーバ側の API 定義が変われば JSON ファイルを更新してリビルドするだけでクライアントも追随する。
使ってみた感想:
- 良かった点 — API の追加・変更がコンパイルエラーとして検出される。手書きクライアントと違い、フィールド名や型のズレがビルド時に発覚する
- 気になった点 — 生成されたメソッドのシグネチャが直感と異なることがある。例えば builder パターンではなく positional args で呼び出す API になっていた。手書きなら
client.list_stories().status(s).awaitと書けるところがclient.list_stories(project_id, status, limit, offset).awaitのような形になる - 向いている場面 — サーバと CLI を同一リポジトリで管理していて、API の drift を型レベルで防ぎたいとき。OpenAPI の drift check (
openapi:check) と組み合わせると、「定義がズレていたらビルドが通らない」という強いガードになる
生成されたクライアントの使用例(シグネチャは progenitor 版のイメージ):
// builder パターンではなく positional args で呼び出す
let stories = client
.list_stories(project_id, Some(status), Some(limit), None)
.await?;
次に試す(未実施)
progenitorのbuilderfeature が追加されたかどうか確認する。バージョンによっては builder スタイルの生成が選べるかもしれない
バーンダウンの理想線は「初回スコープ固定」ではなく日次スコープのスナップショットで引く
イテレーションのバーンダウンチャートを実装するとき、最初に「コミットされたスコープポイント」を1つ凍結して理想線を引くのが素直な発想だが、途中でスコープが増減すると理想線と実線がズレて意味を失う。今回は、スコープ自体も日次スナップショットに含めて、各日の理想線をその日のスコープを基準に引くように変えた。
最初の実装はこんな構造だった。
- イテレーションテーブルに
burndownScopePoints(初回コミットスコープ)を持たせる - 1日1回スナップショットを取り、
remaining_pointsだけを記録する - 理想線は
burndownScopePointsを始点として直線で引く
これだと、途中で「やっぱりこのストーリーも入れる/落とす」が起きたときに、理想線は古いスコープのまま固定されて、現実とまったく合わなくなる。新しい構造はこう。
- スナップショットテーブルに
scope_pointsカラムを追加(migration 0041) - 毎日のスナップショット時に
remaining_pointsとscope_pointsを両方記録する - 当日のスコープが残っていなければ live 集計(
Rejectedを除いた合計)でフォールバック - イテレーション側の
burndownScopePointsは捨てる
// Before: 初回スコープを凍結
await freezeScopeIfUnset(db, iterationId, committedScope);
await db.insert(snapshots).values({
iterationId,
snapshotDate: today,
remainingPoints: remaining,
});
// After: 日次でスコープも記録
await db.insert(snapshots).values({
iterationId,
snapshotDate: today,
scopePoints: scope, // ← 当日のスコープも保存
remainingPoints: remaining,
});
ポイントは、「変動しうる前提値」をスナップショットの外に置かないこと。理想線・実線・スコープ線の3本を同じ時系列ベースで描けるようになるので、後からバーンアップにも自然に拡張できる。
今回やったこと(実施済み)
- マイグレーションでスナップショットに
scope_pointsを追加 - 日次バッチ・API両方で
scope_pointsを保存 - 理想線は「各日のスコープ」を anchor に再計算
- バーンアップタブを追加するときに、同じ
scope_points列をそのまま使えた
