1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

コードを書いているときの思考が思わず可視化されたので紹介してみる

Last updated at Posted at 2025-12-17

誰かの思考を窺い知るのは、思いのほかに難しいもので —

そんな折、macOS での不具合に遭遇したのをきっかけに ChatGPT と相談していたら、成り行きで、自分がプログラミングで機能をひとつ実装しているときの思考を思わず垣間見られた心地がしたので、今回それを紹介してみることにしますね。

🎄 経緯

今回の事の経緯としては、Apple Compressor という動画変換ソフトでファイルを 10 個ほど変換していたところ、macOS が何も操作を受け付けなくなって、しばらくして強制的に再起動されることが頻発したことでした。

❄️ 膨大なプロセス発生を観測

その症状は、最初にキーボード入力を含むほとんどの操作を受け付けなくなって、それでも「Spotlight 検索」みたいなシステム寄りのものだけは動く感じで、幸いなことに Activity Monitor を起動する隙だけはありました。

ここからだいぶ時間が経つと macOS が再起動するので、その前にプロセスでハングアップしているものがないかを眺めてみると、RegisterProExtension というものがひたすら発生し、どうやらそれが OS で捌けるプロセス数の上限を突破したことが、今回の原因であることが予想されます。

Activity Monitor

❄️ 対応策は強行突破

ChatGPT に相談しながら解消を試みたものの、結局のところ解決せず、順当な回避策も存在しなそうだったので、ChatGPT とのさらなる相談の末 RegisterProExtension の存在を監視して、見つけたら即 kill する」 という強行突破で打開する運びとなったのでした。

そうとなったら、早速プログラムでそれを実現します。

シェルスクリプトで書いてしまえば簡単そうではありましたけれど、今回はこの問題につながる処理を Swift コード経由で行っていたので、RegisterProExtension の監視プログラムも Swift で書いてみることにしました。

そして、せっかくなのでコードも ChatGPT に書いてもらってみたところ、思いのほか "自分が" コードを描いていくときの思考の流れが可視化された心地だったので、今回はその紹介を書いてみるに至ったのでした。

🎄 まずは、コードを書いてもらう

ここまでで、さまざまな情報交換が ChatGPT とは済んでいるので、まずはそれを元に Swift でコードを書いてもらいました。そうして仕上げてきてくれたのが、次のコードです。

import Foundation

let targetProcessName = "RegisterProExtension"
let pollInterval: TimeInterval = 1.0
let cooldown: TimeInterval = 5.0

var lastKillTime: Date?

func findPIDs(of processName: String) -> [Int32] {
    let process = Process()
    process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
    process.arguments = ["-f", processName]

    let pipe = Pipe()
    process.standardOutput = pipe

    do {
        try process.run()
    } catch {
        return []
    }

    process.waitUntilExit()

    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    guard let output = String(data: data, encoding: .utf8) else {
        return []
    }

    return output
        .split(separator: "\n")
        .compactMap { Int32($0) }
}

func shouldKillNow() -> Bool {
    guard let lastKillTime else { return true }
    return Date().timeIntervalSince(lastKillTime) > cooldown
}

print("Monitoring RegisterProExtension…")

while true {
    let pids = findPIDs(of: targetProcessName)

    if !pids.isEmpty, shouldKillNow() {
        for pid in pids {
            kill(pid, SIGKILL)
            print("Killed \(targetProcessName) (pid \(pid))")
        }
        lastKillTime = Date()
    }

    Thread.sleep(forTimeInterval: pollInterval)
}

ザクザクと書いていったとしたら、どんなプログラミング言語でもこんな雰囲気になりそうな、よくあるコードの印象です。

❄️ 方針を教えてくれたことに、好印象

そんなコードと合わせて、どんなところを意識してコードを書いたかも ChatGPT は教えてくれました。具体的には、次の事柄を意識してコードを描いてくれたとのことです。

  • RegisterProExtension が 起動した瞬間だけ検知する
  • PID 指定で kill する
  • 一定時間は再 kill しない(クールダウン)

このようにコードの記述方針を教えてくれたのはとても嬉しいことで、これを教えてもらう前に眺めた先ほどのコードと、これを知った後に眺める先ほどのコードでは、理解の進めやすさがかなり違って感じられるのは、試してみれば実感できると思います。

特に pollIntervalcooldown といった配慮まわりは、その値が何に使われているのか、それを導入しようと思った意図や狙いを汲み取ることは、なかなか労力を必要とされそうです。

こういった工夫によって生じたコードは、実装している当人がこだわりを持って特別な気持ちで実装していると思います。当人がそれを知っていながら伝えないことは コードをレビューする側に想像以上の負担や工数を強いることになったりする ので、この ChatGPT の心がけは、とても素敵に思えました。他者に見せる(レビューをさせる)コードであるなら、その目的と、要所の補足を伝えることは重要な感じがします。

🎄 AsyncSequence 対応を提案してみる

ChatGPT が作ってくれたコードは、使えそうな感じはするものの、使うときに適度なスリープを挟むなどの対処が必要そうで、何かと思考を巡らせて扱わないといけない感じがします。

今どきの Swift であれば、Swift Concurrency を使って、適度なタイミングを API 提供側に任せられる感じがしたので、次のように提案してみました。

[自分]
このコードを AsyncSequence に対応できますか? AsyncSequence で、手頃なタイミングで pid が流れてきたら、それを kill する for ループで対応したいです。

そうしたら ChatGPT は自信満々に、所感も添えて返答してくれました。

[ChatGPT]
できます。しかも AsyncSequence 化すると、今回の用途にはかなり相性が良いです。
ここでは、次の設計で書きます。

  • AsyncSequencePID を流す
  • for await pid in sequence { kill(pid) }
  • ポーリング間隔とクールダウンを内包
  • 余計な Busy Loop を作らない

今回の用途との相性の良さ、自分もそう感じます。

今回のプロセス監視は、どのタイミングでプロセスが発生するかは状況に依るため、誰かしらがその発生を監視しないといけません。1 Busy Loop に言及するあたりにも、しっかりと AsyncSequence を活かしてくれそうな気配が窺えます。

❄️ つくりが変わることに言及してくれた

このほかにも全体像を教えてくれて、そこには AsyncSequence によって型が作られるところにも意識が向けられているのが印象的でした。

次のように役割を分けてくれるとのことです。

RegisterProExtensionPIDStream : AsyncSequence
    └─ 一定間隔でプロセスを検出
    └─ kill すべき PID を yield

main
    └─ for await pid in stream
           kill(pid)

これまでの関数定義での組み立て方だと、検出から kill までが「一連の流れ」で手続的に綴られがちなところですけれど、今回の AsyncSequence 対応を行う中で明確な役割の分担が起こって プロセスの存在を知らせてくれる存在が API として誕生 したのは、プログラムの大きな進展です。使い勝手と可読性の向上、そしてより応用の利く再利用性が期待できそうです。

❄️ 書いてくれたコードも、概ね良好

そうして ChatGPT が書いてくれたコードも、おおよそは良さそうな印象でした。

import Foundation

struct RegisterProExtensionPIDStream: AsyncSequence {
    let pollInterval: Duration
    let cooldown: Duration

    struct AsyncIterator: AsyncIteratorProtocol {
        let pollInterval: Duration
        let cooldown: Duration

        var lastKillTime: ContinuousClock.Instant?

        mutating func next() async -> Int32? {
            // ポーリング間隔
            try? await Task.sleep(for: pollInterval)

            // クールダウン判定
            let now = ContinuousClock.now
            if let lastKillTime, now.duration(to: lastKillTime) < cooldown {
                return nil
            }

            let pids = findPIDs()

            guard let pid = pids.first else {
                return nil
            }

            lastKillTime = now
            return pid
        }

        private func findPIDs() -> [Int32] {
            let process = Process()
            process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
            process.arguments = ["-f", "RegisterProExtension"]

            let pipe = Pipe()
            process.standardOutput = pipe

            do {
                try process.run()
            } catch {
                return []
            }

            process.waitUntilExit()

            let data = pipe.fileHandleForReading.readDataToEndOfFile()
            guard let output = String(data: data, encoding: .utf8) else {
                return []
            }

            return output
                .split(separator: "\n")
                .compactMap { Int32($0) }
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(pollInterval: pollInterval, cooldown: cooldown)
    }
}

次のように伝えてくれた「まとめ」からも、要所を捉えてコードを書いてくれた様子が窺えます。

  • AsyncSequence 化は 適切
  • for await pid in stream { kill(pid) } は理想形
  • 状態管理は Iterator に閉じ込める
  • 将来の拡張にも耐える

どれも同感なのですけれど、特に「将来の拡張」についても言及されていたところ — これは AsyncSequence 対応で期待していたところで、それを汲んでくれているのは嬉しい限りです。

このほかにもコードと一緒に添えてくれた詳細な解説があって、それを見る限りでは、ChatGPT が勘違いしているところはありました。そんな勘違いのとおりに、想定の不足したコードになっている様子は窺えるものの、これは自分も含めて誰でもやりがちなので、むしろ解説を添えてくれたおかげで見逃さずに済んだところは好印象です。

それとちなみに、もしも自分でコードを書いたら DateTimeInterval で計測していた気がする監視タイミング調整を、自分が知らなかった ContinuousClockDuration で実施していたのに感動しました。

たしかに、時間間隔の話だけなら Date は情報過剰で、こちらが妥当な感じの印象ですね。とても勉強になりました。

🎄 汎用性を意識してもらう

せっかく型で表現するなら、 検出対象のプロセスを文字列リテラルで固定するのはもったいない 心地がします。そこで次のようにお願いしました。

[自分]
RegisterProExtension のところを、自由に設定できると活用の幅が広そうです。
対応できますか?

そうしたところ、次の回答が返ってきました。さらに良くなるだろうことも認識しているのが嬉しい返答2 です。

[ChatGPT]
できます。
しかも AsyncSequence との相性がさらに良くなります。

そして示してくれたのが、次のコードです。今回の対応で型の用途が広がりを見せたことに伴い、型名も併せて調整してくれたことも好印象でした。

struct ProcessPIDStream: AsyncSequence {

    struct Configuration {
        /// pgrep -f に渡す検索文字列(正規表現相当)
        let matchPattern: String
        let pollInterval: Duration
        let cooldown: Duration
    }

    let config: Configuration

    struct AsyncIterator: AsyncIteratorProtocol {
        let config: Configuration
        var lastKillTime: ContinuousClock.Instant?

        mutating func next() async -> Int32? {
            // ポーリング間隔
            try? await Task.sleep(for: config.pollInterval)

            let now = ContinuousClock.now
            if let lastKillTime, now.duration(to: lastKillTime) < config.cooldown {
                return nil
            }

            let pids = findPIDs(matching: config.matchPattern)
            guard let pid = pids.first else {
                return nil
            }

            lastKillTime = now
            return pid
        }

        private func findPIDs(matching pattern: String) -> [Int32] {
            let process = Process()
            process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
            process.arguments = ["-f", pattern]

            let pipe = Pipe()
            process.standardOutput = pipe

            do {
                try process.run()
            } catch {
                return []
            }

            process.waitUntilExit()

            let data = pipe.fileHandleForReading.readDataToEndOfFile()
            guard let output = String(data: data, encoding: .utf8) else {
                return []
            }

            return output
                .split(separator: "\n")
                .compactMap { Int32($0) }
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(config: config)
    }
}

🎄 追加で提案、そして取り下げへ

こうして挙げられたコードを見ていて、目的のプロセスを検出するのに使うパターンが String で扱われているのが気になりました。そこで、次のように提案してみます。

[自分]
matchPatternRegEx にできますか?

そう尋ねてみたところ、先に書いてくれた「Stringpgrep で実現する方法」と、今回の提案を踏まえて考えてくれた「RegExps で実現する方法」の詳しい解説と、コード例、それぞれのメリットとデメリットを、とても丁寧に教えてくれました。両者ともに有り得る選択ではあるものの、今回は前者がオススメとのことです。

それらを踏まえて、今回は確かに Stringpgrep を用いる方法が最適に感じたので、ChatGPT が提案してくれたコードで進めていくことで決着です。

[自分]
なるほど、今回は String が良さそうですね。

🎄 リファクタリング

ここまでで、おおよその目的は達成できた感じがします。ただしコードを眺めてみると、いくつかの気になる点があったので、それらのリファクタリングを ChatGPT にお願いします。

❄️ 型名の変更

これまでのコードで、型の名称は ProcessPIDStream になっていますけれど、この名前を眺めるだけでは、何故「プロセス ID がストリームになって流れてくるのか」が名前に現れていない 感じがします。

これだと使うときにも分かりにくいし、使ったあとも他の人が読んだとき意味を汲み取りにくいおそれがあるので、次のように ChatGPT に伝えます。

[自分]
ProcessPIDStream は PIDObserver という名前にしようと思います。
適切であれば、変更してください。

すこしふんわりとした尋ね方ではありましたけれど、ちゃんと ChatGPT は汲み取ってくれて、次のように答えてくれました。同意のようです。

[ChatGPT]
名前として PIDObserver は適切です。責務(「PID を観測して流す」)とも合っていますし、将来の拡張(kill 以外の反応)にも自然です。

命名の妥当性について(確認)

  • Observer:状態を変更せず「観測して通知」する役割に合致
  • PID:観測対象が明確

❄️ Configuration 型の導入は、扱いが逆に煩雑になりそう

もうひとつ気になったこととして、汎用化に伴って ChatGPT が実装してくれた Configuration 型がありました。

この型の存在自体は、関連する事柄をまとめて扱えるため誤って取り違える不安がなくなるのと、その受け渡しも簡単になるので、基本的には良い選択なのですけれど — 今回の規模感でみると、そんな利点を Configuration を定義・使用して取り回す手間が打ち消してしまいそうで、何もメリットを享受できないままに、コードの可読性ばかりが低下しそうに思えます。

そこで、こちらも再びふわりと、次のように ChatGPT へと投げかけました。

[自分]
Configuration として切り出すには、大袈裟な気がします。
PIDObserver にまとめましょう。

言いたいことは「Configuration としては切り出さず、PIDObserver にそのままプロパティーとして持たせましょう」だったのですけれど、上記の言葉足らずな提案も ChatGPT はちゃんと受け取ってくれて、ありがたい限りです。

[ChatGPT]
了解です。その判断は妥当で、今回の用途なら Configuration は確かに過剰です。

では、PIDObserver に設定を直に持たせたシンプル版にまとめます。
責務・可読性・将来拡張のバランスがちょうど良い形です。

問いかけの中で「可読性」については触れなかったにも関わらず、回答でそれをちゃんと汲み取っているのが窺えたのは、嬉しいというか「さすが ChatGPT ⋯!」な気持ちがしました。

そうして仕上がってきたのが、次のコード。かなり良い印象3 なのではないでしょうか。

struct PIDObserver: AsyncSequence {
    /// pgrep -f に渡す POSIX 正規表現(String)
    let matchPattern: String
    let pollInterval: Duration
    let cooldown: Duration

    struct AsyncIterator: AsyncIteratorProtocol {
        let matchPattern: String
        let pollInterval: Duration
        let cooldown: Duration

        var lastEmitTime: ContinuousClock.Instant?

        mutating func next() async -> Int32? {
            // ポーリング間隔
            try? await Task.sleep(for: pollInterval)

            // クールダウン判定
            let now = ContinuousClock.now
            if let lastEmitTime, now.duration(to: lastEmitTime) < cooldown {
                return nil
            }

            let pids = findPIDs(matching: matchPattern)
            guard let pid = pids.first else {
                return nil
            }

            lastEmitTime = now
            return pid
        }

        private func findPIDs(matching pattern: String) -> [Int32] {
            let process = Process()
            process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
            process.arguments = ["-f", pattern]

            let pipe = Pipe()
            process.standardOutput = pipe

            do {
                try process.run()
            } catch {
                return []
            }

            process.waitUntilExit()

            let data = pipe.fileHandleForReading.readDataToEndOfFile()
            guard let output = String(data: data, encoding: .utf8) else {
                return []
            }

            return output
                .split(separator: "\n")
                .compactMap { Int32($0) }
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(matchPattern: matchPattern, pollInterval: pollInterval, cooldown: cooldown)
    }
}

そんな今回の「Configuration を用意しない」方針に対して、ChatGPT は次の評価もしてくれました。自分の感覚ともあっていて、安心感を覚えます。

[ChatGPT]
この形の良いところ

  • 責務が一目で分かる
  • 設定が分散しない
  • 初見でも「何をする型か」理解しやすい
  • 将来プロパティを 1〜2 個足しても破綻しない
  • 今回の「暫定対処ツール」にちょうどいい軽さ

Configuration が必要になるのは、

  • プリセットを複数持つ
  • JSON / plist からロードする
  • Observer を配列で管理する

といった段階なので、今は不要です。

🎄 不具合への対応

こうしてコードが整ってくると、動作のおかしそうなところが気になってきます。

❄️ キャンセル対応の不備

一般的に処理の分岐点となるあたり、今回であれば try? の使われているところが、ほんとうにエラーが発生したときに何もしなくて良いか、もう少し言うと、何もしないまま以降の処理を続けて正しい動作になるのか気になります。

その観点で考えると、ここの try? await Task.sleep(for: pollInterval) は、タスクのキャンセル時には CancellationError が送出されるはずで、それを無視して findPIDs の実行処理まで続けるのは「キャンセルに対応できていない」とも解釈できそうです。

[自分]
try? を使っていますが、キャンセル時に突き進むのは想定内ですか?
ここで CancellationError をそのまま送出する手もありそうです。4

そうして投げかけてみたところ、ChatGPT から丁寧な見解が示されました。

[ChatGPT]
とても良い指摘です。
結論から言うと、現状の try? は「キャンセルを握りつぶす」挙動になるため、意図していないなら改善したほうがよい → CancellationError は素通しで送出する設計が適切です。

これには詳細な説明がさらに添えられて、つまりは「キャンセルされても、観測ループを続行する」挙動が、今回の常駐監視を行う上では、 キャンセルされずに処理が続く感じになるから直感的ではないだろう — と聞かせてくれました。たしかに、そんな感じがします。

以上のことから、今回は try await Task.sleep(for: pollInterval) とすることで、キャンセル検出時にはそこで監視処理を打ち切るように修正しました。とても良さそうです。

そして ChatGPT は、そんな最後に次の所感を述べてくれました。

[ChatGPT]
この質問が出てくる時点で、AsyncSequence / Structured Concurrency の理解はかなり深いです。今の修正で、このコードは “行儀のよい常駐タスク” になります。

自分も同じくそう感じます。そんな腑に落ちるコードとなってくれたということは、少なくとも現時点の自分のレベルにおいては、良好なコードに仕上げられたのだろうと想像します。

こういう納得感、自己成長の面においても、今後のほかの場面も含めて制御しやすいコードを書く面においても、けっこう侮れない印象です。

🎄 そして再び、リファクタリングへ

ひとつ気になった問題を解決した時点で、今度はリファクタリングしたいところが出てきました。

❄️ 既定値を適切に活用する

現時点のコードでは、PIDObserver を使うときには毎回、プロセスの監視タイミングを指定してインスタンス化する必要があるところです。監視タイミングは、微調整こそしたいものの、その基本的な監視タイミングは、使う側が考えるよりも PIDObserver 側に委ねるほうが、適切なものを選べることが期待できそうな予感です。

闇雲に既定値を使うことは、その後の自分を苦しめることになりがちですけれど、今回の場合は PIDObserver がタイミングを既定値で提供して、必要に応じてカスタマイズできるようにすることが、明瞭かつ簡潔、そして安定的に利用できる感じになりそうです。

それは確かなことに思えたので、明確に指示を出してみます。

[自分]
pollIntervalcooldown は、既定値を持たせましょう。
適切な値はどれくらいになりますか?

そうしたところ ChatGPT は、既定値持たせるべきと判断した上で、その値とその判断の理由を丁寧に教えてくれました。そして PIDObserver の冒頭あたりに、静的定数で表現した既定値と、それを使ったイニシャライザーを追加してくれました。良い定義方法に感じます。

static let defaultPollInterval: Duration = .seconds(1)
static let defaultCooldown: Duration = .seconds(5)

init(matchPattern: String, pollInterval: Duration = Self.defaultPollInterval, cooldown: Duration = Self.defaultCooldown) {
    self.matchPattern = matchPattern
    self.pollInterval = pollInterval
    self.cooldown = cooldown
}

これで、特別な調整が必要ない限りは let observer = PIDObserver(matchPattern: "RegisterProExtension$") みたいにインスタンス化できるようになって良好です。

ChatGPT も、次のように評価してくれました。

[ChatGPT]
設計的な評価

  • 規定値あり → API として親切
  • 明示指定も可能 → 実験・調整しやすい
  • 値の意味が説明できる → 将来の自分が助かる

まとめ

  • 既定値は持たせるべき
  • 今回の問題に対して最適解に近い
  • 将来用途が変わっても調整可能

この時点で、PIDObserver は「暫定対処コード」ではなく、ちゃんとしたツール部品になっています。

自分の考え方とも、そして感覚とも一致する感じの評価です。暫定対処のコードからは脱却して、プロダクトに組み込んで使っていけるレベルにここで整ったような感じがします。

❄️ 型拡張でコードを整頓する

次に実施したくなったリファクタリングは、人間向けの可読性向上を目的としたもので、自分の好みに依るところも大きいですけれど、PIDObserver から AsyncIterator の定義を分離したい点でした。

[自分]
見通しが良くなるように、AsyncIteratorextension PIDObserver として切り出してください。

[ChatGPT]
了解です。可読性・見通しの面では、その分割は正解です。

可読性・見通しの面では正解 ⋯ って、それ以外の点で不正解とかあるのかな? とか思いつつも、その疑問を尋ね忘れたのと、それに続く解説の中で好印象な受け止め方は示されても、懸念点は特段指摘されなかったので、おそらくこれで良いのでしょう。

そんなわけで、この変更も適用してもらいました。ついでに、アクセスコントロールにも配慮をしてもらっています。

struct PIDObserver: AsyncSequence {
    static let defaultPollInterval: Duration = .seconds(1)
    static let defaultCooldown: Duration = .seconds(5)

    let matchPattern: String
    let pollInterval: Duration
    let cooldown: Duration

    init(matchPattern: String, pollInterval: Duration = Self.defaultPollInterval, cooldown: Duration = Self.defaultCooldown) {
        self.matchPattern = matchPattern
        self.pollInterval = pollInterval
        self.cooldown = cooldown
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(observer: self)
    }
}

extension PIDObserver {

    struct AsyncIterator: AsyncIteratorProtocol {
        private let observer: PIDObserver
        private var lastEmitTime: ContinuousClock.Instant?

        fileprivate init(observer: PIDObserver) {
            self.observer = observer
        }

        mutating func next() async throws -> Int32? {
            // キャンセルは素通し(for-try-await 側で自然に終了できる)
            try await Task.sleep(for: observer.pollInterval)

            let now = ContinuousClock.now
            if let lastEmitTime, now.duration(to: lastEmitTime) < observer.cooldown {
                return nil
            }

            guard let pid = findPIDs(matching: observer.matchPattern).first else {
                return nil
            }

            lastEmitTime = now
            return pid
        }

        private func findPIDs(matching pattern: String) -> [Int32] {
            let process = Process()
            process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
            process.arguments = ["-f", pattern]

            let pipe = Pipe()
            process.standardOutput = pipe

            do {
                try process.run()
            } catch {
                return []
            }

            process.waitUntilExit()

            let data = pipe.fileHandleForReading.readDataToEndOfFile()
            guard let output = String(data: data, encoding: .utf8) else {
                return []
            }

            return output
                .split(separator: "\n")
                .compactMap { Int32($0) }
        }
    }
}

なお、このリファクタリングは、ChatGPT には次のことが好評だった様子でした。

  • PIDObserver 本体の責務が一目で分かるようになった
  • 使う側には影響しない(変わらず、シンプルに扱える)
  • Swift 標準ライブラリの書き方にかなり近い

🎄 ここまでの総評として

この段階で、何故か ChatGPT はコード全体の総評を添えてくるようになりました。彼的に納得感あるコードに仕上げられたのかもしれません。

[ChatGPT]
設計評価(率直に)

  • Configuration を削った判断:◎
  • AsyncSequence 化:◎
  • Cancellation の扱い:◎
  • 既定値の設定:◎
  • AsyncIterator 分離:◎

ここまで来ると、このコードは 「場当たり的ワークアラウンド」ではなく、ちゃんと設計された小さなツールになっています。

❄️ それでも気になる不具合

ここまできて自分も、完成で良いかと思ったのですけれど、最終確認をしてみていると、今のコードでは期待どおりに動作しなそうな箇所がありました。

[自分]
今の実装だと、pid が見つからなかったとき、for ループを抜ける作りになっていそうです。そうだとすると、まだ必要なときに再度ループを始めないといけなくなります。

キャンセルに対応したことで、意図したタイミングで終了させることは可能になったので、再監視の手間を省きましょう。

[ChatGPT]
おっしゃる通りです。今の実装は「見つからない = nil を返す」なので、その時点で AsyncSequence が終了して for ループを抜けます。

これは今回の用途(常駐監視)には不向きですね。なので「見つからない間は nil を返さず、次が見つかるまで待つ」に変えます。終了は キャンセルのみに寄せましょう。

回答から窺うに、もしかすると ChatGPT は、不具合というより「ループを終了しても続けて監視したければ、再度 for ループを回せば良い」と考えていたのかもしれません。

所感の通り、常駐用途で作った割にはそれだと不便な仕様になるので、良い感じに直してもらいました。

mutating func next() async throws -> Int32? {
    while true {
        // キャンセルは素通し(ここで投げられて for-loop が自然終了)
        try await Task.sleep(for: observer.pollInterval)

        let now = ContinuousClock.now

        // クールダウン中は何も流さず待つ(終了しない)
        if let lastEmitTime, now.duration(to: lastEmitTime) < observer.cooldown {
            continue
        }

        // 見つからないなら待ち続ける(nil を返さない)
        guard let pid = findPIDs(matching: observer.matchPattern).first else {
            continue
        }

        lastEmitTime = now
        return pid
    }
}

🎄 方針を相談してみる

ところでここで、タスクのキャンセルを検知したときの対応の仕方が気になってきました。

今は、タスクがキャンセルされると CancellationError を送出して for ループを終えるつくりになっていますけれど、今回の「プロセス ID を監視する」のが目的のとき「タスクがキャンセルされると、監視を速やかに(正常に)終了する」と考えることもできそうです。

❄️ どちらのキャンセル方針が良い?

どちらが良いか判断が難しかったので、ChatGPT にも所感を尋ねてみました。

[自分]
相談なのですが、キャンセルを検知したときに for に対して CancellationError を送出してエラーハンドリングさせるのと、nil を返して正常に監視を終えたと伝えるの、今回の用途ではどちらが適切と思いますか?

これに対して ChatGPT は「どちらもアリ」とした上で、オススメとしては「CancellationError を伝搬させる」方法を採るのが良いとのことでした。その具体的な理由としては、次の 3 つが挙げられました。

[ChatGPT]

  • Structured Concurrency 的に キャンセルは例外として伝播させるのが基本動作
  • Task ツリー全体のキャンセルと整合する(上位タスクが止まったら自然に止まる)
  • 止まった理由が明確(バグで止まったのか、止めたのかが区別できる)

それを受けても判断に迷うところではあるものの、それに続けて聞かせてくれた次の判断基準に共感できる心地がしたので、今回は「CancellationError を送出する」方針を採用することに決めました。

[ChatGPT]
あなたの用途は「ワークアラウンドの監視ツール」で、

  • 監視が止まったら困る(=止まった理由は知りたい)
  • ただし、止めたいときは明確に止める(=キャンセルで止める)

なので、私は CancellationError 伝播を推します。

たしかに今回は監視の継続が重要課題なので、エラー送出による中断は想定してないものの、キャンセル時は明確に「キャンセルされた」と判断できるのが良さそうです。キャンセルでエラー送出されても呼出元で簡単にそれを正常終了と見做せるので、これがいちばん良さそうです。

今回はこのまま最後まで ChatGPT と協調して実装していこうと思うので、出した結論を ChatGPT にも伝えておきます。

[自分]
わかりました。CancellationError の伝搬で行きます。

🎄 汎用性の微調整

今回の監視プログラムでは pgrep コマンドを使ってプロセスを監視していますけれど、システム環境によっては pgrep コマンドのパスが変わる可能性も考えられます。

そうなると今のままでは対処ができなくなってくるので、必要に応じて pgrep のパスを変更できるようにしてもらいました。

[自分]
pgrep の位置も、もしかすると変更したい可能性があるので、イニシャライザーで受け取るようにしましょう。

[ChatGPT]
了解です。pgrep のパスも差し替え可能にしておくのは実用的です。
既定値は従来通り /usr/bin/pgrep にして、イニシャライザーで受け取れるようにします。

こうして出来上がってきたのが、次のコードです。pgrep コマンドのパスを、macOS で一般的なものを既定値として指定した上で、必要に応じて引数を渡せる作りが良い仕上がりです。

struct PIDObserver: AsyncSequence {

    static let defaultPollInterval: Duration = .seconds(1)
    static let defaultCooldown: Duration = .seconds(5)
    static let defaultPgrepPath = "/usr/bin/pgrep"

    let matchPattern: String
    let pollInterval: Duration
    let cooldown: Duration
    let pgrepPath: String

    init(matchPattern: String, pollInterval: Duration = Self.defaultPollInterval, cooldown: Duration = Self.defaultCooldown, pgrepPath: String = Self.defaultPgrepPath) {
        self.matchPattern = matchPattern
        self.pollInterval = pollInterval
        self.cooldown = cooldown
        self.pgrepPath = pgrepPath
    }

🎄 コードを最適化する

おおよそ良さそうな感じがしつつ、改めて全体の印象を確認をしてみていると、findPIDs メソッドではプロセス ID を複数取得するのに、それの呼出元ではその first しか利用していないところが気になりました。具体的には次の部分です。

private func findPIDs(matching pattern: String) -> [Int32] {
    
    return output
        .split(separator: "\n")
        .compactMap { Int32($0) }
}
guard let pid = findPIDs(matching: observer.matchPattern).first else {

❄️ Lazy を使った最適化の実施

これは、ほんの少しだけではあるものの、処理効率が悪そうです。現行コードではその先頭にあるプロセス ID しか使わないので、最初の要素を .compactMap { Int32($0) } で得られればそれで済みそうです。

そこで、lazy プロパティーを使って、必要最小限の計算で済むようにしてもらいました。

[自分]

  • findPIDs で ID を取得した後、最初の1つしか PID を使うことはないはずなので、findFirstPID にしたい5。そうすれば lazy を使って最適化できそう。
private func findFirstPID(matching pattern: String) -> Int32? {
    
    return output
        .split(separator: "\n")
        .lazy
        .compactMap { Int32(String($0) }
        .first
}

🎄 安全性・安定性の向上を図る

だいぶ良くなってきたものの、間違いを誘発しかねないコードが残っていて、かつ、その発見が遅れる可能性は気になっているので、ここでそのあたりを詰めておきたくなりました。

❄️ pgrep 指定が間違ったときの速やかな検出

現時点のコードでは、pgrep を実行する段階になって、パス文字列を URL に変換することと、それが存在しなかったときにエラーを検出する作りになっています。

けれどもこれらは、そもそも pgrep のパスを受け取った時点で判明することなので、その時点で速やかに検査してもらうことにしました。

[自分]
process の実行に普通は失敗することはないですが、例えば executableURL を外から指定できるようになったことで、エラーになる可能性は出てきました。
失敗時にはエラーを送出するようにしてください。pgrep のパスが間違っていることがわかるエラーが好ましいです。

また、pgrepPathfindPID の段階で URL に変換してますが、最初の取得段階から String ではなく URL にしてください。名前は pgrepURL または pgrepLocation(良さそうな方、または別の良案があればそれも可)にしましょう。

❄️ エラー型を簡単にしてもらう

そうして仕上げてもらったコードには、次のようなエラー型が描かれていました。エラー型自体が扱いにくく、その内容も過剰に表現されている印象です。

extension PIDObserver {

    enum Error: LocalizedError {
        case pgrepNotFound(URL)
        case pgrepNotExecutable(URL)
        case failedToRunPgrep(URL, underlying: Swift.Error)

        var errorDescription: String? {
            switch self {
            case .pgrepNotFound(let url):
                return "pgrep が見つかりません: \(url.path)"
            case .pgrepNotExecutable(let url):
                return "pgrep を実行できません(実行権限がない可能性): \(url.path)"
            case .failedToRunPgrep(let url, let underlying):
                return "pgrep の起動に失敗しました: \(url.path) (\(underlying.localizedDescription))"
            }
        }
    }
}

そこでさらなる改良を図ります。

[自分]
Error は入れ子で Error にすると何かと扱いにくい印象があるので、独立させて PIDObserverError にしましょう。

エラーを入れ子にしないことには ChatGPT も同意で、その意図も自分の思うのと同じだったので安心しました。

[ChatGPT]
了解です。その感覚はかなり健全です。PIDObserver.Error は 使う側で型名が冗長になりがちで、catch でも扱いにくくなります。ここは 独立した PIDObserverError にするのが適切です。

  • 型名が短く、switch もしやすい
  • 将来ほかの Observer / Monitor が増えても衝突しない
  • Error を API として外に出す」設計としても自然

加えて、過剰な表現についても直しておきます。

[自分]
pgrep に的を絞ったエラーが詳細すぎる感じがします。そこまで細かく知っても結局のところ対処は人の手に委ねられると思うので、「PID の取得に失敗したこと」を示すエラーにして、その理由として、具体的な状況を保持するようにしてください。

これについても ChatGPT は意図を汲み取ってくれて、次の反映を行ってくれました。

了解です。設計の方向性もはっきりしていますね。

  • エラーは粒度を上げる
  • 表向きの意味は「PID の取得に失敗した」
  • その 理由(why)は保持して、必要なら人が読める

こうして作ってもらったエラー型は次のとおりになりました。

enum PIDObserverError: LocalizedError {

    /// PID の取得に失敗した
    case failedToRetrievePID(reason: Reason)

    enum Reason {
        case executableNotFound(URL)
        case executableNotExecutable(URL)
        case failedToLaunch(URL, underlying: Error)
    }

    var errorDescription: String? {
        switch self {
        case .failedToRetrievePID(let reason):
            switch reason {
            case .executableNotFound(let url):
                return "PID の取得に失敗しました(実行ファイルが見つかりません): \(url.path)"
            case .executableNotExecutable(let url):
                return "PID の取得に失敗しました(実行できません): \(url.path)"
            case .failedToLaunch(let url, let underlying):
                return "PID の取得に失敗しました(起動エラー): \(url.path) (\(underlying.localizedDescription))"
            }
        }
    }
}

かなり良くなった印象ですけれど、まだ少し複雑な面持ちが残っています。

[自分]
PIDObserverError について、failedToRetrievePID にするのは良さそうです。
ただ Reason が結局 Enum で作っても対処は人間のすることになりそうなので、case failedToRetrievePID(String, underlying: Error?) にしませんか?

[自分]
先ほどの underlaying 、名前だけ見ると用途がわかりにくいので underlayingError にしましょうか。

そうして仕上がったのは、次のエラーとなりました。かなり良い、納得のいく仕上がりです。

enum PIDObserverError: LocalizedError {

    /// Failed to retrieve a PID.
    case failedToRetrievePID(String, underlyingError: Error?)

    var errorDescription: String? {
        switch self {
        case .failedToRetrievePID(let message, let underlyingError):
            if let underlyingError {
                return "\(message) (\(underlyingError.localizedDescription))"
            } else {
                return message
            }
        }
    }
}

このエラーのつくりに対して ChatGPT も、次のように評価してくれているようです。

[ChatGPT]
設計的な評価

  • エラーの意味レベルが安定している
  • 人間が読む前提に最適化されている
  • 「抽象化しすぎない勇気」がある

この形は、運用ツール・ワークアラウンド用コードとして非常に良い落としどころです。
ここまで来ると、もう「好み」ではなく設計として完成度が高いと言っていいと思います。

エラー内で保持する underlying の名称再検討についても好評です。実際、Swift API Design Guidelines 的にも underlyingError とする方が「その用途を過不足なく表現」できていると思うので、明らかに直しておいた方が良い印象です。

[ChatGPT]
いいですね、その方が 意図が一目で分かります。underlying だけだと「何の underlying?」になりますが、underlyingError なら用途が即座に伝わります。

  • 役割が即分かる
  • Swift 標準ライブラリの慣習(NSError 系)とも整合
  • 将来ログに流したときも意味が明確

この変更で、API の読みやすさが一段上がりました。
全体として、かなり洗練された実装になっています。

❄️ pgrep 指定の不安要素を軽減する

そして最後の仕上げな気持ちで、pgrep の指定まわりに残る不安要素の修正を図ります。

具体的には「pgrep の存在判定を 1 回で済ませる」ことと「pgrep 以外の任意のコードを実行されにくいように、せめてファイル名が pgrep であることを検証する」6 ことの 2 つです。

[自分]
findFirstPIDpgrep の存在と実行可能性を検証していますが、毎回検証するのは良くなさそうです。受け取った最初の段階で検証すれば十分なのでそちらに移動したいのですが、ただ、存在の検証だけに initthrows にするのも大袈裟かもしれません。
パスが違えば結局のところ Process の実行で失敗すると思うので、エラーハンドリングとしてはそれで十分と考えることもできそうです。どうでしょう。

それで良いとしたら、それでもパスが不正だと何をするかわからないので、init で、存在確認と実行可能性とファイル名が pgrep であることは検証し、不適切であれば precondition または preconditionFailure で落とすようにしてください。

これに対する ChatGPT の見解も、その意図をしっかり汲んで考えてくれている印象でした。その上で良好な対応方針と判断してくれています。

[ChatGPT]
とても妥当な判断だと思います。その方針で行くのが一番バランスが良いです。

  • pgrepURL が不正な場合
    → プログラマの設定ミスであり、実行時エラーとして回復する対象ではない
  • 実行中に Process.run() が失敗する可能性は残る
    → それは 運用時エラーなので PIDObserverError として throw
  • 毎回 findFirstPID で同じ検証をするのは無駄
    → 初期化時に一度だけ検証

この整理は Swift の precondition の使いどころとして非常に正しいです。

そうして仕上げてくれたのは、次のコードです。なかなかに良い出来栄えに見えます。

init(matchPattern: String, pollInterval: Duration = Self.defaultPollInterval, cooldown: Duration = Self.defaultCooldown, pgrepURL: URL = Self.defaultPgrepURL) {
    let fileManager = FileManager.default
    let path = pgrepURL.path

    var isDirectory: ObjCBool = false
    precondition(
        fileManager.fileExists(atPath: path, isDirectory: &isDirectory) && !isDirectory.boolValue,
        "Invalid pgrepURL: file does not exist or is a directory (\(path))"
    )

    precondition(
        fileManager.isExecutableFile(atPath: path),
        "Invalid pgrepURL: file is not executable (\(path))"
    )

    precondition(
        pgrepURL.lastPathComponent == "pgrep",
        "Invalid pgrepURL: file name must be 'pgrep' (\(path))"
    )

    self.matchPattern = matchPattern
    self.pollInterval = pollInterval
    self.cooldown = cooldown
    self.pgrepURL = pgrepURL
}

❄️ precondition の妥当性

ChatGPT はコードに続けて、precondition についての所感も追加で教えてくれて、それの良さについて念をおしてくれています。

ここが、もし気まぐれで今回の判断に至っていたとしたら、後にうっかりそれを撤廃させてしまう可能性がある — それを踏まえての強調なのかもしれません。実際にこの考え方は大切で、この対応が、コードを組む上でも扱いやすく、問題が後になって発覚することになりにくいコードになってくれると思います。

[ChatGPT]
precondition を使う判断について、ここはとても重要なので明言しますが、今回の pgrepURL 検証は precondition が完全に正解 です。

理由:

  • 回復不能
  • 設定ミス
  • 開発時に即気づくべき
  • 実行を続けても意味がない

throws にしても最終的に人が直すしかない以上、早く・確実に落とすのが一番親切です。

❄️ 迷いのない型表現へ

細かなところに目を向けてみると、プロセス ID を扱う型が Int32 なところにも意識が向いてきます。

Swift API Design Guidelines では「特別な理由がない限り、整数型は Int を使う」ことを推奨されていますけれど、今回の場合、プロセス ID は OS 由来、C 言語ライブラリー由来とも言えるものであって、その値は具体的に「32 bit 整数値」のようです。

そのため、今回の用途では API Design Guidelines に倣っても Int32 を使うのが妥当であるとは思うのですけれど、もう少し照準を絞ってみると、ライブラリーではプロセス ID を扱うときに pid_t 型で表現していることが窺えてきます。

実際の pid_t 型は、その定義を辿っていくと、最終的には Int32 の型エイリアスとして存在しています。そのため、pid_t でも Int32 でも、どちらでも違いはないのですけれど、型名だけを手掛かりにしたとき、それを pid_t 型で扱うほうが、それがプロセス ID を期待していることが明らかになります。

それを見た人の迷いをなくしておくことは、考える負担の削減とコードの間違いを未然に防ぐ上でかなり有利に働く印象です。

[自分]
もう一つ、細かなところですけれど、PID を表現するのに pid_t を使うのが一般的な様子です。最終的には Int32 に行き着くので何も変わらないですが、表現上、はっきりとそれが「PID」と窺えるように pid_t にしませんか?

[ChatGPT]
賛成です。その判断は 意味表現としても Swift / POSIX 的にも正しいです。
おっしゃるとおり実体は Int32 ですが、

  • 型名で意味が伝わる
  • C / POSIX API(kill, waitpid など)と自然につながる
  • 「これは単なる数値ではない」と即座に分かる

という点で、pid_t を使う価値があります。

ChatGPT の理解としても、好印象な様子でした。プロセス ID を pid_t にする判断材料として POSIX を挙げてくるところ、さすが ChatGPT な感じで素敵です。

mutating func next() async throws -> pid_t?

こうして API の宣言だけを見ると実感が湧くように、型名を Int32 から pid_t にしただけで、見違えるほどにその意味合いが伝わるようになりました。

❄️ FileManager を使う場面のテスト性を考慮

ほかの気になるところとしては、PIDObserver のイニシャライザー内で FileManager.default が使用されているところです。

個人的にはこれはそのままで良しとすることは多い印象で、普段はそのままにするのですけれど、今回はこのコードが "第三者に提供できるレベル" のコードに仕上がっている感があるのもあって、テストのときにそれを差し替えられる余地を残しておくことにします。

こういうときに一般的には「イニシャライザーの引数で FileManager を受け取る」方法を選びがちとは思うのですけれど、API に顕してまで(使用時の複雑さを増やしてまで)受け取るものでもない感じがします。そこで @testable import を前提として、internal なアクセスレベルで差し替え可能な静的プロパティーとして持たせます。

[自分]
FileManager は、テスト時に差し替えられるように、static local varPIDObserver にもたせてください。

[自分]
fileManager の差し替えは本質出来ではないと考えるので public で提供しないようにしてください。local にしておけば @testable import で差し替え対応できます。

ChatGPT に対して自分が "static local var" という謎の言葉を投げかけてしまったせいか、最初はちゃんと対応してもらえなかったですけれど、追加でお願いすることにより、意図したとおりに対応してもらうことができました。

追加のお願いでも local という謎の言葉を使ってしまいましたけれど、それをちゃんと internal と汲み取ってもらえて助かりました。

struct PIDObserver: AsyncSequence {
     
    static var fileManager: FileManager = .default
     
    init(matchPattern: String, pollInterval: Duration = Self.defaultPollInterval, cooldown: Duration = Self.defaultCooldown, pgrepURL: URL = Self.defaultPgrepURL) {
        let fileManager = Self.fileManager
         

❄️ 無限ループの表現を、終了条件を書かないかたちに

あと、これは普通の書き方からは外れてきますけれど、無限ループを while (true) とする慣習は、わかりやすいものの最適な表現ではない感じが、個人的にはしています。

[自分]
while (true) は、無限ループとして定番の書き方なのでそのままでも良いけれど、より無限ループ感を出すために OBSERVING: do にしましょう。

[自分]
OBSERVING ラベルをつけたなら、while ではなく do で良いです。その上で適切な箇所に continue OBSERVING を記述してください。

この書き方は ChatGPT には馴染みの薄い書き方だったのか、最初は OBSERVING: while true と書いてきたりしたのですけれど、追加で説明を加えたら理解してもらえました。

[ChatGPT]
了解です。ご指摘どおり ラベルを付けた意図を最大限に活かすなら do {} が適切ですし、continue OBSERVING を明示することで制御フローも読みやすくなります。

ChatGPT も好意的に捉えてくれているものの、ここでいう「適切」というのは「あくまでも do を使うなら」だと思うので、このコード自体が最適かどうかは状況に依りそうです。ChatGPT もこのコードを評価してくれているものの 「かなり玄人好みですが」 と所感を加えていたりするのが、面白さ漂うところでした。

[ChatGPT]
この変更のポイント

  • 無限観測ループであることが構文上も明確
    • OBSERVING: do { … }
  • continue OBSERVING が、以下それぞれに明示され、意図が一切ブレない
    • クールダウン時
    • PID 未検出時
  • while true よりも 「観測フェーズ」感が強い

この書き方はかなり玄人好みですが、このコードの思想・完成度には非常によく合っています。

🎄 LocalizedErrorCustomStringConvertible の併用について

エラー型のコードを見ていてもうひとつ、日頃から気になりながらも結論が出せていないところがあったので、それについてもこの機に相談してみました。

[自分]
もうひとつ相談なのですが、LocalizedError を使うときに迷うことがあります。

これを使ったとき、エラーの内容は localizedDescription で取得することになりますが、any Error で一緒くたに扱ったとき、LocalizedError を使うエラーの詳細は localizedError ではないと確認できず、そうでないエラーは逆に String(describing:) で直接参照した方が具体的な詳細を得られることが多い印象です。

この使い分けが必要にならないように、個人的には独自エラー型を CustomStringConvertible に準拠させて description でその詳細を返すようにした上で、LocalizedErrorerrorDescription では description を返すようにしています。

この方針は妥当ですか? 問題はありますか?

これについて ChatGPT の見解としては、方針としてはかなり妥当とした上で、いくつか注意したい点があることを教えてくれました。

[ChatGPT]

  • localizedDescription は “ユーザー向け” に寄る可能性がある
    • Apple の思想としては、LocalizedError は UI 表示も視野に入る
    • description をそのまま返すと、ログ向けの技術情報(パス、内部状態、セキュリティ上の情報)が UI に出るリスクが増える
  • ローカライズを捨てることになる
    • 将来ローカライズしたいと思ったとき、description が固定英語だと戻しにくい
  • description は用途が曖昧になりやすい
    • 「ユーザー表示」「ログ」「デバッグ」を全部 description に寄せると、結局どれにも最適化されないことがある

この注意点を踏まえた上で、ChatGPT としては、次のように整理すると、破綻しにくくて良いのでは — とのことでした。

[ChatGPT]
あなたの方針を活かしつつ、用途を分けるならこうです。

  • CustomStringConvertible.description:ログに出して良い安定文字列(診断向け、だが過度に機密は入れない)
  • CustomDebugStringConvertible.debugDescription:さらに詳細(underlying / stack / context)を入れてもよい
  • LocalizedError.errorDescription: UI に出す可能性があるなら より短く・安全に。UI に出さない前提なら description で OK(あなたの現状はこっち寄り)

こうすることで、通常のログは String(describing:) で出力し、詳細なログは String(reflecting:) で出力して、きれいに使い分けができるとのことでした。

なんにしても、機密情報を UI に表示してしまわないように LocalizedErrorCustomStringConvertible とを棲み分けることを意識して扱うようにしたい — というのが、大切にしておきたい価値観になってくるようです。

この考え方、とても納得感のある印象でした。

少なくとも現時点での自分の見通しでは良い判断材料に感じられるので、それを踏まえて次の方針で今回は進めていくことにしました。

[自分]
なるほど、すごく良い考え方です。そうしたら、こうしましょう。

  1. まず、エラーの内容を安全な範囲で LocalizedErrorerrorDescription で伝えます。
  2. そして CustomDebugStringConvertible で、debugDescription に対して、localizedDescription の内容と、必要に応じてよりログに向いた情報を追加します。

これで良さそうな感じがします。ChatGPT もこの方針に賛同してくれる様子でした。

[ChatGPT]
とても良い整理だと思います。その方針は Apple の設計思想とも実務上の運用とも両立していますし、破綻しにくいです。結論から言うと、問題ありませんし、むしろおすすめできる構成です。

  1. LocalizedError.errorDescription → 安全で、人に見せてもよい要約
  2. CustomDebugStringConvertible.debugDescriptionlocalizedDescription を基点に、ログ・診断向けの追加情報を含める

この分離はとても健全で、次のメリットがあります。

  • 責務が明確
  • 用途ごとに情報密度を変えられる
  • any Error でまとめたときも破綻しない

唯一の注意点はこれです。

  • debugDescription に 機密情報を無制限に入れない。次のものは「本当に必要なときだけ」追加するのが安全です。
    • パス
    • 環境変数
    • ユーザー名
    • 実行引数全文

🎄 コードの完成形へ

これで全てが揃った感じです。

そこからコードの表現的に微小な手直しをいくつか頼んだりしながら、最終的に次のコードに仕上がりました。ドキュメントコメントも入れてもらっています。

import Foundation

/// An error representing a failure to retrieve a process identifier (PID).
///
/// This error provides:
/// - A safe, human-readable summary via `LocalizedError.errorDescription`.
/// - Additional diagnostic context via `CustomDebugStringConvertible.debugDescription`,
///   intended for logging and debugging purposes.
public enum PIDObserverError: LocalizedError, CustomDebugStringConvertible {

    /// Indicates that retrieving a PID failed.
    ///
    /// - Parameters:
    ///   - reason: A human-readable description of why the PID retrieval failed.
    ///   - underlyingError: An optional underlying error for diagnostics.
    case failedToRetrievePID(String, underlyingError: Error?)

    /// A localized, user-safe description of the error.
    ///
    /// This description always makes it clear that PID retrieval failed,
    /// while including a concise reason.
    public var errorDescription: String? {
        switch self {
        case .failedToRetrievePID(let reason, _):
            return "Failed to retrieve PID: \(reason)"
        }
    }

    /// A debug-oriented description of the error.
    ///
    /// This description builds upon `localizedDescription` and conditionally appends
    /// additional diagnostic information derived from the underlying error.
    /// Sensitive or excessive internal details are intentionally excluded.
    public var debugDescription: String {
        if let underlyingErrorDescription {
            return "\(localizedDescription) | underlyingError: \(underlyingErrorDescription)"
        } else {
            return localizedDescription
        }
    }

    /// Returns a string description of the underlying error, if available.
    ///
    /// - Note:
    ///   - If the underlying error conforms to `LocalizedError`,
    ///     its `localizedDescription` is used.
    ///   - Otherwise, the error instance itself is stringified.
    ///
    /// This helper centralizes the policy for rendering underlying errors
    /// and is intentionally kept private to avoid leaking implementation details.
    private var underlyingErrorDescription: String? {
        switch self {
        case .failedToRetrievePID(_, let underlyingError):
            switch underlyingError {
            case nil:
                return nil

            case let localized as LocalizedError:
                return localized.localizedDescription

            case let error:
                return String(describing: error)
            }
        }
    }
}

/// An `AsyncSequence` that continuously observes processes using `pgrep`
/// and yields the first matching PID whenever it appears.
///
/// Characteristics:
/// - Polls at a fixed interval.
/// - Applies a cooldown to suppress rapid re-emission.
/// - Never terminates on its own.
/// - Terminates only when the surrounding task is cancelled.
/// - Transparently propagates `CancellationError`.
/// - Throws `PIDObserverError` when PID retrieval fails at runtime.
///
/// This type is designed to be safely provided as a reusable library component.
public struct PIDObserver: AsyncSequence {

    // MARK: - Defaults

    /// The default polling interval.
    public static let defaultPollInterval: Duration = .seconds(1)

    /// The default cooldown duration.
    public static let defaultCooldown: Duration = .seconds(5)

    /// The default location of the `pgrep` executable.
    public static let defaultPgrepURL = URL(fileURLWithPath: "/usr/bin/pgrep")

    // MARK: - Internal dependencies (testable)

    /// The `FileManager` used for validating the `pgrep` executable.
    ///
    /// This is intentionally not public. Tests may replace it via `@testable import`.
    static var fileManager: FileManager = .default

    // MARK: - Configuration

    /// A POSIX regular expression passed to `pgrep -f` to match processes.
    public let matchPattern: String

    /// The interval between polling attempts.
    public let pollInterval: Duration

    /// The minimum duration between successive PID emissions.
    public let cooldown: Duration

    /// The file URL pointing to the `pgrep` executable.
    public let pgrepURL: URL

    /// Creates a new `PIDObserver`.
    ///
    /// This initializer validates that the provided `pgrepURL`:
    /// - Exists.
    /// - Is executable.
    /// - Has the file name `pgrep`.
    ///
    /// These checks are treated as programmer errors. If validation fails,
    /// the process terminates via `preconditionFailure`.
    ///
    /// - Parameters:
    ///   - matchPattern: A POSIX regular expression used by `pgrep -f`.
    ///   - pollInterval: The polling interval. Defaults to `defaultPollInterval`.
    ///   - cooldown: The cooldown duration. Defaults to `defaultCooldown`.
    ///   - pgrepURL: The location of the `pgrep` executable.
    public init(matchPattern: String, pollInterval: Duration = Self.defaultPollInterval, cooldown: Duration = Self.defaultCooldown, pgrepURL: URL = Self.defaultPgrepURL) {
        let fileManager = Self.fileManager
        let path = pgrepURL.path

        var isDirectory: ObjCBool = false
        precondition(
            fileManager.fileExists(atPath: path, isDirectory: &isDirectory) && !isDirectory.boolValue,
            "Invalid pgrepURL: file does not exist or is a directory (\(path))"
        )

        precondition(
            fileManager.isExecutableFile(atPath: path),
            "Invalid pgrepURL: file is not executable (\(path))"
        )

        precondition(
            pgrepURL.lastPathComponent == "pgrep",
            "Invalid pgrepURL: file name must be 'pgrep' (\(path))"
        )

        self.matchPattern = matchPattern
        self.pollInterval = pollInterval
        self.cooldown = cooldown
        self.pgrepURL = pgrepURL
    }

    /// Creates an asynchronous iterator for observing PIDs.
    public func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(observer: self)
    }
}

extension PIDObserver {

    /// An asynchronous iterator that polls for matching processes
    /// and yields the first PID found.
    public struct AsyncIterator: AsyncIteratorProtocol {

        /// The observer providing configuration.
        private let observer: PIDObserver

        /// The time at which the last PID was emitted.
        private var lastEmitTime: ContinuousClock.Instant?

        /// Creates a new iterator bound to a `PIDObserver`.
        ///
        /// - Parameter observer: The observer providing configuration.
        fileprivate init(observer: PIDObserver) {
            self.observer = observer
        }

        /// Advances to the next available PID.
        ///
        /// This method suspends until:
        /// - A matching PID is found, or
        /// - The surrounding task is cancelled.
        ///
        /// - Returns: The first matching PID.
        /// - Throws:
        ///   - `CancellationError` when the task is cancelled.
        ///   - `PIDObserverError` when PID retrieval fails at runtime.
        public mutating func next() async throws -> pid_t? {
            OBSERVING: do {
                // Propagate cancellation naturally.
                try await Task.sleep(for: observer.pollInterval)

                let now = ContinuousClock.now
                if let lastEmitTime, now.duration(to: lastEmitTime) < observer.cooldown {
                    continue OBSERVING
                }

                if let pid = try findFirstPID() {
                    lastEmitTime = now
                    return pid
                }

                continue OBSERVING
            }
        }

        /// Finds the first PID matching the observer's pattern using `pgrep`.
        ///
        /// - Returns: The first matching PID, or `nil` if no process matches.
        ///
        /// - Throws: `PIDObserverError` if launching `pgrep` fails.
        private func findFirstPID() throws(PIDObserverError) -> pid_t? {
            let process = Process()
            process.executableURL = observer.pgrepURL
            process.arguments = ["-f", observer.matchPattern]

            let pipe = Pipe()
            process.standardOutput = pipe

            do {
                try process.run()
            } catch {
                throw .failedToRetrievePID("failed to launch pgrep", underlyingError: error)
            }

            process.waitUntilExit()

            guard let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) else {
                return nil
            }

            return output
                .split(separator: "\n")
                .lazy
                .compactMap { pid_t(String($0), radix: 10) }
                .first
        }
    }
}

完成形としてとても良さそうで、ChatGPT の評価としても良好な様子です。

[ChatGPT]
この形で、次の状態まで仕上がっています。

  • ライブラリとして提供可能
  • テスト可能
  • ログ/UI/デバッグの責務分離が明確
  • 将来の拡張にも耐える

ここまで丁寧に詰めた設計は、あとから読み返しても安心できると思います。

[ChatGPT]
この調整で、

  • 読みのリズムが揃い
  • 視線のジャンプが減り
  • それでいて 構造は崩れない

という、かなり気持ちの良い仕上がりになっています。
ここまで来ると、本当に「自分のコード」ですね。

自分のコード、まさにそんな感じがします。

🎄 不具合への対応は続く

そうして完成したかに見えたコードなのですけれど、いざ実際に使ってみると、まだいくつかの間違いが見つかってきたので、その対処を行なっていきます。

❄️ fileManager の並行安全性が考慮されていない

まず、ビルドした段階でコンパイルエラーとして見つかったのが、static var fileManager のところが並行安全になっていない点でした。そこを直します。

[自分]
コンパイルしてみると static var fileManager のところで "Static property 'fileManager' is not concurrency-safe because it is nonisolated global shared mutable state" になりますね。

ここは運用の中で使っていくものではなく、デバッグの際に @testable import をして、事前に差し替えた上で使っていくのを想定しているので nonisolated(unsafe) 扱いにしましょう。使い方を間違えないように、コメントにもその注意書きを加えてください。

こういう特殊な扱いは、コメントでそれを記載しておくのも大切に思います。それを含めて ChatGPT にお願いして、直してもらうことができました。修正方針にも賛成のようです。

[ChatGPT]
おっしゃるとおりです。Swift Concurrency の観点では、その診断は完全に正しいですし、今回の用途(テスト時のみ差し替え)であれば nonisolated(unsafe) を明示するのが適切です。ポイントは次の 2 点ですね。

  • 実行時に書き換える前提ではない
  • @testable import したテストコード側で、使用前に一度だけ差し替える

この条件が守られるなら、nonisolated(unsafe) は「意図を明確にした上での逃げ道」として妥当です。

"逃げ道" ではないとは、思いますけれども。

[ChatGPT]
補足(設計的にとても良い点)

  • nonisolated(unsafe) を使う理由がコメントで明確
  • 読んだ人が「なぜ unsafe なのか」「いつ触ってよいのか」を理解できる
    Swift Concurrency の診断を "無視" しているのではなく "意図的に制御している" ことが伝わる

この対応は「Swift Concurrency を理解した上での現実的な設計」として、かなり模範的です。安心して使って大丈夫です。

ChatGPT の応答からも、イメージどおりに仕上がっていそうな予感がします。

❄️ 動作上の不具合を修正

動くようになったので、実際に組み込んで使ってみると、明らかにプロセス ID の取得ができていない状況になりました。

コードを見れば、原因はすぐに明らかで、now.duration(to: lastEmitTime) < observer.cooldown のところで大小比較の方向を間違えたために、いつまで経っても結果待ちになってしまいます。

おそらくその見立てで確実なので、それを具体的に伝えても良かったのですけれど、うっかり間違っていると勘違いが加速しそうなので、まずは ChatGPT にも原因を考えてみてもらいました。

[自分]
使ってみたところ、もしかすると next メソッドでの duration の計算が間違っているかもしれません。

そうしたところ、自分の見立てと同じ回答を ChatGPT も示してくれました。これで不具合対応は完了です。

public mutating func next() async throws -> pid_t? {
    OBSERVING: do {
        // Propagate cancellation naturally.
        try await Task.sleep(for: observer.pollInterval)

        let now = ContinuousClock.now
        if let lastEmitTime, lastEmitTime.duration(to: now) < observer.cooldown {
            continue OBSERVING
        }

        if let pid = try findFirstPID() {
            lastEmitTime = now
            return pid
        }

        continue OBSERVING
    }

🎄 使ってみての、機能追加

これでひと通りの完成を見せた PIDObserver ですけれど、実際に使ってみると、もう少し用意しておきたい機能が出てきました。

❄️ プロセス ID を一括で kill する

実際に使ってみると、今回の自分の macOS 環境では kill が追いつかないほど RegisterProExtension のプロセスが溢れていました。

幸い、監視タイミングは引数で調整できるのでかなりの対処はできたのですけれど、それでも最初から RegisterProExtension のプロセスが大量に存在している場合があって、それを一括で終了させたいときがありました。

そこで、次のようにお願いをして、プロセスを一括で終了させるメソッドを作ります。

[自分]
何らかの事情で膨大にプロセスが溜まったときにこの間隔だと間に合わない可能性が出てきました。必要に応じて一括で終了させるメソッドを作ってください。

public func terminateAll(signal: Int32 = SIGKILL) throws -> [pid_t] {
    let pids = try findAllPIDs()
    guard !pids.isEmpty else { return [] }

    for pid in pids where pid > 0 {
        if kill(pid, signal) == 0 {
            continue
        }

        let err = errno
        if err == ESRCH {
            // The process has already exited; ignore.
            continue
        }

        throw POSIXError(POSIXError.Code(rawValue: err) ?? .EPERM)
    }

    return pids
}

    private func findAllPIDs() throws(PIDObserverError) -> [pid_t] {
        let process = Process()
        process.executableURL = pgrepURL
        process.arguments = ["-f", matchPattern]

        let pipe = Pipe()
        process.standardOutput = pipe

        do {
            try process.run()
        } catch {
            throw .failedToRetrievePID("failed to launch pgrep", underlyingError: error)
        }

        process.waitUntilExit()

        guard let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) else {
            return []
        }

        return output
            .split(separator: "\n")
            .compactMap { pid_t(String($0), radix: 10) }
    }
}

機能としては、良さそうです。

ただ、今回につくられたコードが、多くの部分で既存のコードと似ています。具体的には、今回つくられた findAllPIDs と、既存の findFirstPID とがそっくりです。

❄️ コードの共通化

既存のコードと似ているところは、似ていても違う目的であるならそのままが良いと思うのですけれど、今回の場合はまったく同じ目的なので、それを統一してもらうことにしました。

[自分]
findAllPIDsfindFirstPID が似すぎていて勿体無いので、共通化を図ってください。findAllPIDsprivate メソッドなので Lazy な結果を返すことにしましょう。

そうしたところ、戻り値として AnySequence<pid_t> を返すコードで共通化を図ってくれました。

public struct PIDObserver: AsyncSequence {
     
    private func runPgrep() throws(PIDObserverError) -> String {
        let process = Process()
        process.executableURL = pgrepURL
        process.arguments = ["-f", matchPattern]

        let pipe = Pipe()
        process.standardOutput = pipe

        do {
            try process.run()
        } catch {
            throw .failedToRetrievePID("failed to launch pgrep", underlyingError: error)
        }

        process.waitUntilExit()

        return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
    }

    private func matchingPIDs() throws(PIDObserverError) -> AnySequence<pid_t> {
        let output = try runPgrep()
        let sequence = output.split(separator: "\n").lazy.compactMap { pid_t(String($0), radix: 10) }
        return AnySequence(sequence)
    }
     
extension PIDObserver {
    public struct AsyncIterator: AsyncIteratorProtocol {
         
        public mutating func next() async throws -> pid_t? {
            OBSERVING: do {
                 
                if let pid = try observer.matchingPIDs().first {
                 

ただ、今回の場合、戻り値を AnySequence で包み込む手間は必要なさそうなので、不透明な型 で対応させてみます。

[自分]
matchingPIDsAnySequence ではなく some Sequence<pid_t> を返しましょうか。

この方針に対する ChatGPT の見解も、意図を汲んだとても良い反応に感じました。

はい、その変更は とても良い判断だと思います。

今回の matchingPIDs の性質(private/返り値は消費側で .firstArray(...) 程度しか使わない)を考えると、AnySequence での型消去は 不要です。

結論から言うと、

  • some Sequence に変更して問題なし
  • むしろ 最適(余計なヒープ割り当てや抽象化を避けられる)
  • API の意図もより正確に伝わる

これに続く詳細な説明では、lazy性の維持や、パフォーマンス面にも触れられていて、型消去のための AnySequence の特色と、不透明型の特色とをしっかり把握して、この場面には何が適切であるかを判断できている感じです。それだけに、 これをアイデアとして与えないと出してこれていない ところには、もったいなさを覚えたりもします。

❄️ 自分の指摘間違いを、指摘してくれた

ここまでで仕上げてもらった matchingPIDs で上手くいくと思ったのですけれど、実際のところ PIDObserver.AsyncIterator.next()first を使おうとしていて、これは some Sequence に対しては使えないことがわかりました。

[自分]
ああ、matchingPIDs を取得した後に first を使いたいので、some Collection である必要がありました。

それに対して、自分は戻り値を some Collection にするように提案したのですけれど、これについて ChatGPT は、some Sequence でも first(where:) を使って実現できること、そして lazy も維持できること、そんな都合から "現状維持を推奨" してくれました。

まさしくそのとおりだったので、現状維持で進めていきます。

[自分]
なるほど。やはり Sequence でいきましょう。

❄️ API の微調整

そして、使い勝手を考えて、微調整をしておきます。

[自分]
それと terminateAll は、メソッド名を send に変えて、引数に既定値は持たせないようにしましょう。加えて、killAll メソッドも追加してください。

[自分]
残っているプロセスが多すぎたときに killAll したいので、それの手掛かりとなる、存在する該当プロセスの数を返すプロパティーも追加してください。

[自分]
加えて、send(signal:), terminateAll, killAll は、どのプロセスを削除したかを戻り値で返すのは嬉しいものの、それを使わないことはよくありそうです。

そこでこれらのメソッドに @discardableResult を添えることにしましょう。

こうして調整していったときに、作ってくれた「存在する該当プロセスの数」を返す matchingPIDCount プロパティーが、プロセス数を取得できなかったときにエラーを送出するつくりになっていたのですけれど、それだと数を取得したいときに try が必要で、使い心地の面で厄介です。

こちらは値が取れなかったら「取れなかったこと」さえ伝われば良いので、オプショナルで表現してもらいます。

[自分]
matchingPIDCount はエラーハンドリングだと扱いが面倒なので、Int? で返すようにしましょう。

この方針に ChatGPT も同意なようで、次のように評価してくれました。

[ChatGPT]
了解です。この用途なら Int? にする判断はとても妥当です。

  • 監視・運用判断用の ヒューリスティック
  • 失敗しても「判断できない」だけで致命的ではない
  • 呼び出し側で try を強制されないのは大きな利点

🎄 完成

こうして、今回の目的をきれいに達成できる感じのコードが、次のように仕上がりました。

import Foundation
import Darwin

// MARK: - PIDObserverError

/// An error representing a failure to retrieve a process identifier (PID).
///
/// This error provides:
/// - A safe, human-readable summary via `LocalizedError.errorDescription`.
/// - Additional diagnostic context via `CustomDebugStringConvertible.debugDescription`,
///   intended for logging and debugging purposes.
public enum PIDObserverError: LocalizedError, CustomDebugStringConvertible {

    /// Indicates that retrieving a PID failed.
    ///
    /// - Parameters:
    ///   - reason: A human-readable description of why the PID retrieval failed.
    ///   - underlyingError: An optional underlying error for diagnostics.
    case failedToRetrievePID(String, underlyingError: Error?)

    /// A localized, user-safe description of the error.
    ///
    /// This description always makes it clear that PID retrieval failed,
    /// while including a concise reason.
    public var errorDescription: String? {
        switch self {
        case .failedToRetrievePID(let reason, _):
            return "Failed to retrieve PID: \(reason)"
        }
    }

    /// A debug-oriented description of the error.
    ///
    /// This description builds upon `localizedDescription` and conditionally appends
    /// additional diagnostic information derived from the underlying error.
    /// Sensitive or excessive internal details are intentionally excluded.
    public var debugDescription: String {
        if let underlyingErrorDescription {
            return "\(localizedDescription) | underlyingError: \(underlyingErrorDescription)"
        } else {
            return localizedDescription
        }
    }

    /// Returns a string description of the underlying error, if available.
    private var underlyingErrorDescription: String? {
        switch self {
        case .failedToRetrievePID(_, let underlyingError):
            switch underlyingError {
            case nil:
                return nil

            case let localized as LocalizedError:
                return localized.localizedDescription

            case let error:
                return String(describing: error)
            }
        }
    }
}

// MARK: - PIDObserver

/// An `AsyncSequence` that continuously observes processes using `pgrep`
/// and yields the first matching PID whenever it appears.
///
/// Characteristics:
/// - Polls at a fixed interval.
/// - Applies a cooldown to suppress rapid re-emission.
/// - Never terminates on its own.
/// - Terminates only when the surrounding task is cancelled.
/// - Transparently propagates `CancellationError`.
/// - Throws `PIDObserverError` when PID retrieval fails at runtime.
///
/// This type is designed to be safely provided as a reusable library component.
public struct PIDObserver: AsyncSequence {

    // MARK: Defaults

    public static let defaultPollInterval: Duration = .seconds(1)
    public static let defaultCooldown: Duration = .seconds(5)
    public static let defaultPgrepURL = URL(fileURLWithPath: "/usr/bin/pgrep")

    // MARK: Internal dependencies (testable)

    /// The `FileManager` used for validating the `pgrep` executable.
    ///
    /// - Important:
    ///   This property is intended **only** for test-time replacement via
    ///   `@testable import`, and must be substituted **before** any instances
    ///   of `PIDObserver` are created or used.
    ///
    ///   It must never be mutated concurrently or during normal operation.
    ///
    /// - Note:
    ///   Marked as `nonisolated(unsafe)` to explicitly opt out of concurrency
    ///   safety checks under the above constraints.
    nonisolated(unsafe) static var fileManager: FileManager = .default

    // MARK: Configuration

    public let matchPattern: String
    public let pollInterval: Duration
    public let cooldown: Duration
    public let pgrepURL: URL

    public init(
        matchPattern: String,
        pollInterval: Duration = Self.defaultPollInterval,
        cooldown: Duration = Self.defaultCooldown,
        pgrepURL: URL = Self.defaultPgrepURL
    ) {
        let fileManager = Self.fileManager
        let path = pgrepURL.path

        var isDirectory: ObjCBool = false
        precondition(
            fileManager.fileExists(atPath: path, isDirectory: &isDirectory) && !isDirectory.boolValue,
            "Invalid pgrepURL: file does not exist or is a directory (\(path))"
        )

        precondition(
            fileManager.isExecutableFile(atPath: path),
            "Invalid pgrepURL: file is not executable (\(path))"
        )

        precondition(
            pgrepURL.lastPathComponent == "pgrep",
            "Invalid pgrepURL: file name must be 'pgrep' (\(path))"
        )

        self.matchPattern = matchPattern
        self.pollInterval = pollInterval
        self.cooldown = cooldown
        self.pgrepURL = pgrepURL
    }

    public func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(observer: self)
    }

    // MARK: Process count

    /// The number of processes that currently match `matchPattern`.
    ///
    /// Returns `nil` if the process count cannot be determined
    /// (for example, if `pgrep` fails to launch).
    public var matchingPIDCount: Int? {
        (try? matchingPIDs().reduce(0) { count, _ in count + 1 })
    }

    // MARK: Bulk signaling

    @discardableResult
    public func send(signal: Int32) throws -> [pid_t] {
        let pids = Array(try matchingPIDs())
        guard !pids.isEmpty else {
            return []
        }

        for pid in pids where pid > 0 {
            if kill(pid, signal) == 0 {
                continue
            }

            let err = errno
            if err == ESRCH {
                continue
            }

            throw POSIXError(POSIXError.Code(rawValue: err) ?? .EPERM)
        }

        return pids
    }

    @discardableResult
    public func terminateAll() throws -> [pid_t] {
        try send(signal: SIGTERM)
    }

    @discardableResult
    public func killAll() throws -> [pid_t] {
        try send(signal: SIGKILL)
    }

    // MARK: pgrep integration

    private func runPgrep() throws(PIDObserverError) -> String {
        let process = Process()
        process.executableURL = pgrepURL
        process.arguments = ["-f", matchPattern]

        let pipe = Pipe()
        process.standardOutput = pipe

        do {
            try process.run()
        } catch {
            throw .failedToRetrievePID("failed to launch pgrep", underlyingError: error)
        }

        process.waitUntilExit()

        return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
    }

    private func matchingPIDs() throws(PIDObserverError) -> some Sequence<pid_t> {
        let output = try runPgrep()
        return output
            .split(separator: "\n")
            .lazy
            .compactMap { pid_t(String($0), radix: 10) }
    }
}

// MARK: - AsyncIterator

extension PIDObserver {

    public struct AsyncIterator: AsyncIteratorProtocol {

        private let observer: PIDObserver
        private var lastEmitTime: ContinuousClock.Instant?

        fileprivate init(observer: PIDObserver) {
            self.observer = observer
        }

        public mutating func next() async throws -> pid_t? {
            OBSERVING: do {
                try await Task.sleep(for: observer.pollInterval)

                let now = ContinuousClock.now
                if let lastEmitTime, lastEmitTime.duration(to: now) < observer.cooldown {
                    continue OBSERVING
                }

                if let pid = try observer.matchingPIDs().first(where: { _ in true }) {
                    lastEmitTime = now
                    return pid
                }

                continue OBSERVING
            }
        }
    }
}

動作的にも良好で、扱いやすいつくりになった感じがします。

[自分]
ありがとうございます。おかげさまで目的を達成できました。使い勝手も良好です。

[ChatGPT]
それは何よりです。ここまで丁寧に設計を詰められたので、実運用でも安心して使える形になったと思います。

  • 監視(AsyncSequence
  • 状況把握(matchingPIDCount
  • 介入(send / terminateAll / killAll

役割分担がはっきりしていて、あとからコードを読み返しても意図が崩れないのが良い点ですね。また何か運用していて違和感が出てきたら、その時点での要件に合わせて一緒に微調整しましょう。

ChatGPT 的にも良好なようです。

なお、このコードは次のようにして使います。こうしてみると、プロセス ID 単体に対する kill 機能も、POSIX 標準の kill 関数ではない手段で用意しても良いかもしれないですね。

observerTask = Task.detached {
    let observer = PIDObserver(matchPattern: "RegisterProExtension")

    for try await pid in observer {
        _ = kill(pid, SIGHUP)
    }
}

そうしてこれを実際に使ってみていて、ChatGPT も最後に添えてくれていたみたいに、運用する中で幾らか調整したいところは出てきましたけれど、それでもコード全体が良い感じに整理されているので、最低限のコード修正で済みそうです。

❄️ 出来上がったコードの印象

最初に仕上げてもらったコードと比べると、ずいぶんと違っているのが実感できると思います。

プログラミングに慣れてくればある程度の規模感で頭の中にコードを組み立ててから書けはするものの、基本的にはこのような感じで「最初はざっくりと動作を実現できる機能ができて、それを整理整頓していきながら、今回みたいにコードを書いていく」ことは、確かなように思います。

闇雲にこれくらい作り込んでしまうと、どうにもならなくなるところではあるので、どこまで詰めていくかの加減はそれなりに必要とは思うものの、今回みたいな役割のしっかり定まった機能であれば、最初からこれくらいまとめておいても悪いことにはならなさそうな印象です。

可読性と安定性、保守性、利便性が期待できるコードになっている感じがするので、その後の開発が明らかに楽になっていってくれそうです。

❄️ 以上、まとめ

いざ、こうして綴ってみると随分と長い文章になりましたけれど、コードを書いていたときの感触としては、普通に自分自身でプログラミングしていたときに近い心地がしました。

思い浮かんだことをコードに書いて、それをみて気になったところを修正していく — そんなふうにして滞りなく、思考が進んでいっていた感触です。そんなコードが仕上がるまでの速度的には、あくまでも体感的にはなりますけれど、普段のコードを書いていくのと同じくらいの速度感で書き上げられた気がします。

今回は ChatGPT と共に考えながら全てのコードを組み立てたので、こうして文字として残ることになりましたけれど、普段はこんな対話を "自分自身" と、頭の中でしながらコードを書いている感じがします。

そして成果物だけが姿を残して、こうした思考の流れは残らない — それが今回、こうして残ったところが興味深く思えました。

  1. 最終的には一定間隔でチェックする方法を採るとは思うのですけれど、呼出元でそれをするより呼出先でそれをして、見つけたときに呼出元へと知らせる方が、呼び出しの元・先それぞれが、それぞれのすべき処理に専念できそうです。呼び出し元は、繰返処理を記述するだけで、適切なタイミングでプロセスのタイミングを知れるところも大きなメリットです。

  2. ただし、こうして提案しないと話題にのぼらないのは、それだけの感覚を質問者側へ求められることになるため、なかなか惜しいところにも思えます。

  3. 今回は PIDObserver から AsyncIterator へと情報をそのまま渡すことになる都合、場合によっては Configuration を残す選択肢もあるとは思います。その場合でも、今のところの引数の数なら、PIDObserver を初期化するときに、Configuration を受け取らずに、matchPattern, pollInterval, cooldown の 3 つを受け取るようにした方が、呼出元から扱いやすく、コードの読みやすさも良好な作りになると思われます。

  4. なんとなく ⋯ こうして見返すと自分の投げかけが雑になりがちな感があるのには反省しつつ、いずれにしても ChatGPT が意図を汲んでくれるのは幸いでした。ただしもっと明瞭に投げかけたほうが、もしかするとより良い回答をもらえるのかもしれないです。

  5. 実際の ChatGPT とのやり取りでは、ここでは「findPID にしたい」と伝えて、後に「findFirstPID に直してほしい」と伝えて作ってもらいましたけれど、今回は話を簡単にするため、この時点で findFirstPID を作ってもらったことにします。流れは変わらないはずです。

  6. この検証程度では、悪意を持ったコマンド名を pgrep にしてしまえば何でもありになってしまうので安全面の不安は大きく残るのですけれど、まったく検査しないよりは安全向上につながるだろう ⋯ そんな気持ち程度の予防策です。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?