昨天講身分:誰能開一場、誰看得到哪一場,以及授權為什麼必須掛在連線交出去之前那一刻。
今天講那條連線的另一端:你在瀏覽器裡看到的那個終端到底是什麼東西,我為什麼把它整個重寫,以及在沒有規格的情況下,怎麼確認自己沒改壞。
這一天看起來離審查員最遠,所以先講它跟審查員的關係:終端是使用者接上審查員的主要通道。ttyd 與 Session 容器彼此獨立:連線中途壞掉時,容器裡已開始的審查可能仍繼續,使用者卻接不回去。若只看容器狀態,會以為一切正常;因此今天的驗證不能只盯著審查結果,也要檢查終端通道本身。這是 Day 13 那張表裡的第四種「失敗沒有聲音」。
先把結論放前面:我重寫了 ttyd ✍️(原版、我 Fork 後的版本)。
理由很單純,單純到有點小。在我現在這套反向代理架構裡,ttyd 原本能直接接上的 HTTP 身分檢查有兩種:一種是靜態帳號密碼,一種是 --auth-header,也就是無條件相信反向代理塞進來的標頭。前者不能跟既有的身分系統對接,後者則把判斷權整個交出去,代理設錯就等於沒有。
我要的是另一種:像 Nginx 的 auth_request 那樣,在放行之前先去問一個我自己寫的端點「這個人能不能開這一場」。昨天整篇講的就是這件事,只是昨天那道問答發生在 Nginx,我還想要終端本身也問一次。
最短的路其實很清楚:fork 原本的 C 版,只把 --auth-url 加進去。改動會小很多,也不必主動拿掉原本的 Windows 支援。我沒有走這條路。
我確實 fork 了 repo,但不是在 C 版上補旗標。C 版留下來當行為基準,我另外在 rust/ 底下重新做了一套完整實作:github.com/nathanfhh/ttyd。這不是把 C 逐行翻成 Rust;我只承諾自己會用到的 Unix PTY 路徑,沒有移植 Windows 的 ConPTY,同時加入原版沒有的 forward auth(--auth-url 與相關選項)和 --title。至於這套系統怎麼起它、怎麼替兩顆 binary 分開組參數,在另一個 repo 的 claude-pty/server/views.py。
為了一個旗標重寫一個專案,這個理由聽起來很薄。我自己也覺得薄。但真正讓我決定動手的不是那個旗標值多少,而是另一個問題:一個東西如果我只敢在外面補一塊、不敢重新理解它,那它到底算不算我的?
先把它的地位講清楚,不然後面會誤會這是一個重要元件。
它是一張用完即丟的貼皮。
一場 Session 的真身是容器,昨天前天都講過。ttyd 只是那一場的一張臉:開網頁的時候才起,關掉網頁它自己就退。這件事靠一個 -q 參數做到,意思是所有 client 都斷線就自行結束,所以應用程式不必偵測使用者何時關頁,也不必主動終止 ttyd。至於它退出後的狀態由誰呼叫 wait 領走,是下一個問題。
起它的方式有點繞,值得解釋,因為它決定的是「誰負責回收」。
用程式起另一個程式,起的人就是它的父程序,而父程序有一個義務:孩子結束之後要去把結束狀態領回來(wait)。沒領的話,那個孩子雖然已經終止了,系統還是得替它留一筆紀錄等父親來問。這種只剩一筆紀錄的東西就是殭屍程序。
我的 worker 活很久,而終端什麼時候關掉是使用者決定的,沒有一個適合去等的時機。所以我改成讓它很快脫離起它的 worker:Python 先起一個新的 shell,由 shell 把 ttyd 丟到背景,shell 隨即結束。正式部署的容器開了 init: true:這項 Docker Compose 設定會在容器裡放入一個很小的 init 程序當 PID 1;這裡使用的是 tini。脫離原父程序的 ttyd 會過繼給它(reparenting,孤兒程序一律由 PID 1 收養),結束後也由它呼叫 wait,避免殭屍程序累積。
這個形狀通常稱為 double-fork。重點不是名稱,而是沒有任何 worker 欠這顆 ttyd 一次 wait;這份責任集中交給 PID 1 的 tini。
有一點容易誤會:任何一個權限相符的 worker 都能送訊號終止這顆 ttyd,跟過繼沒有關係。送訊號只要 uid 對得上就行,本來就不必是它的父親,父子關係只決定誰有資格 wait。過繼買到的是另外一件事:起它的那個 worker 重啟或崩潰,ttyd 不受影響。
代價是「照 pid 去終止」本身有風險。pid 會被回收再利用,ttyd 退出之後、紀錄清掉之前,同一個號碼可能已經是別的程序了。所以送訊號之前會先去比對那個 pid 的命令列是不是我們認得的 ttyd,不是就不送。
換句話說,這個系統裡最不重要的一個元件,就是使用者唯一直接看到的那個。
有人會問為什麼不用 Go。Go 是我的第二選擇,不是不能用。
選 Rust 的理由有兩個,一個是體感,一個查得到數字。
體感那個是:C 以外還算貼近底層的語言裡,Rust 是我心裡最接近的那一個。終端伺服器要處理的東西,PTY、訊號、程序群組、檔案描述子,全部都在那個層次,跑不掉。而這幾年順手的開發工具,ruff、oxlint、ty、uv,底下都是 Rust。這件事我不覺得是巧合,只是要說清楚它證明的是什麼:它證明的是「需要貼著系統跑又要快的東西,現在大家會選 Rust」,不是「Rust 比較安全」。那是另一件事。
另一件事才是有數字的那個。我在做的是把一份 C 寫的終端伺服器改寫掉,而 C 這類語言最經典的問題就是記憶體安全。這個題目有幾個公開的數字:
我要誠實補一句:那些是超大型專案幾年下來的統計,而我改寫的是一份不到兩千行的 C 終端伺服器,規模差了好幾個數量級,不能直接套。
但方向是同一個,而且編譯器攔下來的東西,比執行到那一行才炸開便宜太多。這件事在我自己的移植過程裡也應驗了,只是應驗的方式有點難堪,等一下會講。
我後來才看清楚,選 Rust 還有第三個理由:我對 Rust 不夠熟,反而迫使我不能靠熟悉感驗收,只能把證據鏈做完整。 這不是把「不會」說成優勢;剛好相反,它拿掉了「因為是我熟悉的語言,所以我大概看得出來」這條捷徑。我要接受 agent 交回來的成果,就得先定義原版有哪些行為、哪些差異不能出現,以及要用什麼證據才准它算完成。
這條路已經有人做過研究。Microsoft Research 的 RustAssistant 讓 LLM 與 Rust compiler 反覆迭代,在真實開源專案的編譯錯誤上,最高修復率約 74%。這個數字只回答「編譯錯誤有沒有修掉」,不等於功能正確,但它證明 compiler diagnostics 可以成為 agent 能反覆讀取的回饋。
2026 年的 Generative Compilation 更把回饋往前移到生成途中。那篇研究同時講了兩面:Rust 這類具有豐富靜態語意的語言能提供更強的檢查,但嚴格的限制也讓生成更困難;在他們的實驗裡,提早引入 compiler feedback 減少了無法編譯的輸出,也改善了功能正確性。
所以我不會把結論寫成「Rust 比較容易讓 AI 寫對」。比較準確的是:Rust 讓一部分錯誤比較難安靜地混過去,並且把修正所需的回饋變成機器可以反覆消化的形式。但 compiler 只提供證據鏈裡的一部分;真正讓我接受這份成果的,是下面那套驗收迴圈。
先把主語講清楚:這份改寫大半不是我手寫的,是請 agent 做的。所以「我怎麼知道沒改壞」這一題,主語其實是它;下面這套差異測試,是我驗它的方法,不是驗我的。
不熟悉本身不是證據,它只是拿掉「我看起來覺得對」這條捷徑。真正能讓我接受它的,仍然是原版行為、雙向差異、覆蓋率、未解差異與實際使用一起疊出來的證據鏈。
這是整件事最難的部分,比寫程式難。
一個終端伺服器要做對的事情非常瑣碎:三十個命令列選項、四個 HTTP 端點、八種 WebSocket 訊息、三種認證模式、TLS、UNIX socket、權限降級、兩種結束時機的規則。這些東西沒有另外一份完整的規格書,能拿來查驗的行為基準,只有原本那份 C 程式碼實際上做了什麼。
所以測試的寫法要反過來。
一般寫測試是斷言「它應該做什麼」。這裡不行,因為「應該」是我的意見,而我的意見不是規格。這裡要先記錄的是「原本那個實作實際上做了什麼」,再要求重寫版本對每一個差異提出解釋。差異可能是我移植錯了,也可能是我決定修正原版行為;但它不能安靜地混過去。這種先記錄既有行為的做法叫特徵測試(Characterization Test);若把整份既有輸出保存下來當成後續比對基準,則常稱為 Golden Master。它本來就是為既存系統設計測試案例用的,而「既存系統」正是我的處境:那份 C 程式碼已經在那裡,我沒有另外一份完整規格,只有它實際表現出來的行為。
接著把它變成雙向的:同一套測試,用一個環境變數切換打向哪一顆 binary。 一支測試在其中一邊過、另一邊不過,那就是一個必須被解釋的差異。可能是我移植錯了,也可能是原版有問題而我剛好修掉了。這兩種都要當場說清楚是哪一種。
最後還有一個問題沒解決:這套測試沒摸到的地方怎麼辦?
我拿 C 版編了一份帶覆蓋率的 binary,把同一套測試打上去,然後問一個很不舒服的問題:有哪些 C 的行為,是我這套測試從頭到尾沒有碰過的?那些就是我在盲區裡搬過來的部分。
這一步的收穫不是一個數字。它指出兩個地方,而且那兩個都不是「少寫測試」,是我的移植版真的做錯了:
第一個是 --ping-interval,它被解析了然後被忽略。那個選項在我的版本裡完全沒有作用。後果是一個閒置的終端,會被任何有閒置逾時的反向代理直接斷掉。我自己的部署前面就站著一台 Nginx,所以這是一個我遲早會撞上、而且會覺得莫名其妙的問題。修正之後它會照那個間隔送 WebSocket ping,對方超過間隔加七秒沒回應就掛斷,跟 C 的重試策略一致。
第二個是收到終止訊號的時候,子程序沒有跟著結束。關掉伺服器不會拆掉還活著的 session。每個終端的子程序都在自己的程序群組裡;只把終止訊號送給 ttyd 時,核心不會自動把同一個訊號轉送給那些群組。結果是 ttyd 退出了,終端裡的程序卻成為孤兒繼續執行。修正之後結束會傳遞到活著的 session,由它們去通知自己的子程序。
這兩個東西的共同點是:測試全綠,而它們都壞著。 因為沒有任何一支測試碰到那些行為,綠燈只是在說「我檢查過的部分沒問題」。是覆蓋率反過來問,才把它們指出來。
我把正常黑箱測試能驅動的缺口一路補上;最後逐行核對剩餘未覆蓋處,主要是需要故障注入的錯誤路徑,另外還有刻意放慢 client 才可能觸發的 HTTP 部分寫入路徑,以及在目前控制流程下不會走到的程式碼。
這是我自己也想記下來的一段。
覆蓋率如果只給一個數字,讀的人一定會拿去跟另一個數字比,而那個比較多半不成立。所以我把它拆成三個分母寫清楚:
| 量的是誰 | 範圍 | 行覆蓋率 |
|---|---|---|
C 原版(src/*.c,coverage 工具納入 966 行) |
共用的 108 支測試 | 88.72% |
Rust 版(rust/src/*.rs) |
同樣那 108 支 | 80.58% |
Rust 版,扣掉 auth.rs |
同樣那 108 支 | 86.55% |
| Rust 版(coverage 工具納入 2,778 行) | 整套測試,含單元與新功能 | 93.82% |
圖裡的 221 是產生這組覆蓋率時的歷史數量。目前整套測試是 222 支(後來多了一支單元測試),而兩顆 binary 共用的那組差異測試仍然是 108 支。
可以拿來比的只有前兩列,而那個比較對我不利:同一套測試摸到的 Rust 程式碼比 C 少了八個百分點。
差距大部分是 auth.rs,那是 forward auth,C 版沒有這個功能,共用測試從結構上就碰不到它,它由另外 19 支測試守著,而那 19 支沒有對照組可打。扣掉之後差距縮到兩個百分點左右。此外,以同一套原始碼計數方式看,Rust 版接近原版的三倍(2,778 行對 966 行);多出來的很多是 C 沒有對應物的錯誤處理。
最後一列是「這份重寫到底被測到什麼程度」的答案,而它是唯一不該被拿去跟 C 那個數字並排的一個。
雙向測試跑出四個真實差異裡的三個,剩下那一個是後來用手量出來的。我原本以為會抓到的是「C 有的我沒做到」,結果最嚴重的那個是反過來的。
第一版的移植,在 socket 一打開就把視窗標題和偏好設定送出去。C 版不是這樣,它要等瀏覽器先送出開場訊息才回話。
我原本以為這只是順序不同。
問題在於視窗標題的內容預設是完整的命令列。我那個順序,等於在 AuthToken 被檢查之前,就把命令列送給任何一個只是把 socket 打開的人。
發現的當下,我做的第一件事是把順序改回跟 C 一樣。但改完之後我意識到另一件事:C 版其實也一樣,只是它的洩漏發生在驗證之後。每一個合法連上來的人,照樣會收到完整命令列。C 沒有把它當成問題,我猜是因為在單機情境下,命令列本來就是你自己打的。
所以我又加了一個 --title,讓伺服器可以直接把宣告出去的標題整個換掉,命令列一個字都不上線。
回頭看我自己那套的時候,才是真正尷尬的地方。
我早就注意到 ttyd 會把命令列跟容器主機名當成網頁標題,所以我很早就設了 titleFixed,把標題固定成只含那一場的編號。我甚至在程式碼旁邊寫了註解,說明這是為了避免容器編號和 attach 參數洩漏到分頁標題、瀏覽紀錄和截圖裡。
但 titleFixed 是 client 選項。它只改瀏覽器顯示什麼,真正的標題在那之前就已經送過線了。
也就是說,我那行設定做到的是「畫面上看不到」,不是「沒有送出去」。而我的註解寫得像是後者。
這種錯比我一路在清的另一種更難發現。前面幾天講過那種「前提消失了但論證還躺在原地」的東西,至少它讀起來會怪。這一種不會,它讀起來完全合理,只是它宣稱的效果比它實際做到的大。而它更危險的地方在於,下一個看到那行註解的人會認為這件事已經解決,不必再看。
註解我改掉了,改成寫它實際做到的事。而真正解決它的,剛好是我為了別的理由順手加的那個 --title:現在起終端的時候會一起帶上它,伺服器宣告出去的標題就只剩一個固定字樣加上那一場的編號,命令列一個字都不上線。
但這件事只有 Rust 版做得到,那個旗標是我加的,C 版沒有。跑 C 版的時候這個洞還在,只是被 titleFixed 蓋住畫面。這是兩顆 binary 之間一個真實的差別,我把它寫進說明文件裡,因為選 binary 的人應該知道自己在選什麼。
這裡還有一個我一開始想錯的地方。我原本以為把這個旗標塞給 C 版,它會直接拒絕啟動。驗證後才發現,它雖然會把警告寫到 stderr,卻不會因此退出;碰上 --title VALUE 這種帶值的新旗標,VALUE 會被當成要執行的命令,後面每一個真正的參數也跟著被吞進去,連監聽的 port 都會掉回預設值。
所以目前的實作替兩顆 binary 分開組參數,並用測試釘住 C 版拿不到 Rust 專屬旗標。即使 C 版遇到未知旗標不會退出,也不能只憑程序還活著就認定啟動成功;就緒檢查還必須確認指定 port 上真的由 ttyd 提供服務。
不展開,各一句:
程式裡的 PAUSE 是死碼,那個旗標設過一次就再也沒有被改過,流量控制從來沒有真的動過。非正常結束的時候,關閉訊框裡放的是保留碼 1006,而規範明文寫著這個碼不得出現在那裡,嚴格的客戶端會把它當成協定違規。指定 socket 檔案的擁有者卻沒寫群組的時候,那個選項靜靜地什麼都不做,chown 與 chmod 都沒有發生。
重點不是抓到原版三個問題。重點是差異測試兩邊都會響,而響得最大聲的那一次是指向我自己的。
還有一個更值得記的,因為它暴露的是方法本身的極限。
--auth-url 可以選配一層快取,不開的話每一個靜態檔案的請求都要去問一次控制平面。而我給那層快取算的鍵值是錯的:它只涵蓋路徑和幾個標頭,沒有涵蓋請求方法,也沒有涵蓋轉發相關的那組標頭。
後果是一個對 A 請求發出的放行,可能被拿去放行一個 B 請求,而端點根本沒有被問到。
單靠差異測試不可能抓到這個。因為 forward auth 是我加的功能,C 版沒有能回答預期行為的對照實作。差異測試仍能確認關閉新功能時,既有行為沒有被破壞,卻不能替新增功能本身提供答案。
修法是讓快取鍵從「組出去那組標頭的同一份結構」推導出來,這樣兩者不可能各自漂走。但那是修法,不是解方。真正的教訓是:一套驗證方法有多可靠,取決於你有沒有講清楚它罩不到哪裡。
這篇整理到最後,又多出一筆必須認的帳。有一支單元測試,守的是「一場終端的輸入在伺服器裡最多積壓 4 MiB」這條上限。它從某一次重構開始就一直是紅的:就是把 PTY 從每場三條執行緒改成事件驅動的那個 commit。用同一個 Docker 容器一個一個 commit 比對,前一個過,從那一個起不過。然後它就這樣紅著,跨過後面十一個 commit,連整包成果合併進主線那一次也在內,五個多星期沒有人發現。
追下去發現,上限本身從頭到尾沒有壞。照連線層實際套用的閘門去模擬,積壓在 Linux 上的峰值正好停在 4 MiB;在 macOS 上,終端還停在預設的行緩衝模式時根本排不起隊,那顆核心會把多出來的直接丟掉,不是拒絕寫入,實測寫超過 512 MiB,佇列始終是空的。壞掉的是測試的前提。它斷言上限會在寫入 8 MiB 之內被碰到,但寫進去的位元組有多少先被核心吞掉,是各家主機自己決定的事。它量到的是主機,不是我的上限。
這跟上一節是同一個教訓的兩面。上一節說的是方法有罩不到的地方;這一節說的是方法罩到了、燈也亮了,而我沒有在看。紅著沒人讀的測試比沒寫還糟:它繼續躺在測試清單裡,替整條證據鏈撐著一個不存在的可信度。
測試我改掉了,而這件事還有第二層要認:第一次的修法本身又是一盞假綠燈。我把斷言換成一條兩種核心上都該成立的不變量,燈綠了;另一輪審查把它戳破:把 4 MiB 上限整個拔掉,那個版本在 macOS 上照樣過,因為行緩衝模式下根本排不了隊,等於什麼都沒測到。我用來修假綠燈的,是另一盞假綠燈。真正的修法是讓測試裡的子行程先把終端切成 raw 模式,兩種核心從此都會產生背壓,閘門在兩邊都真的會關上。這次我補做了反向驗證:上限拔掉,測試在兩種核心上都要轉紅;上限留著,兩邊都綠。至於「紅燈當天就要被讀掉」這件事,沒有工具能替我做,這次輸掉的是紀律。
前面說四個差異裡有一個是後來用手量出來的,就是關閉碼 1006 那一個。
這篇整理完之後,我回頭把那份對照報告裡的每一條重驗一次,才發現關閉碼那一條,我原本寫的是相反的事:我說 C 版就算正常結束,關閉碼也到不了瀏覽器,所以使用者每次關掉終端,它都會跳出重連。
那句話是錯的。拿釘死的那支發行檔在 wire 上量,正常結束送出的就是 1000,後面接一次正常的收線。換一台機器、換一個 libwebsockets 版本、換成使用者真的在終端裡打 exit 或按 Ctrl+D,四種情境量到的都一樣。錯的是我讀原始碼的推論:那段程式碼先設好關閉理由,再從回呼函式要求關閉連線,我把這兩件配套的事讀成互相抵銷。
真正要認的不是讀錯,是為什麼沒有人攔下我。
掛著那個名字的測試,從它被加進來的第一顆 commit 起就對 C 版整支跳過。理由當時看起來很正當:那是一個「刻意造成的差異」,不必拿原版來跑。於是那個宣稱在整段歷史裡,一次都沒有對 C 版執行過。而真正涵蓋失敗那條路的另一支測試,一直對兩個版本都跑,但它只斷言「關閉碼不是 1000」,而兩邊的結果都滿足這個條件。
一支被跳過的測試,跟一個太弱的斷言,藏住的是不同的東西。前者讓一個錯誤的宣稱從來沒有被檢查,後者讓一個真實存在的差異從來沒有被記錄。單看其中任何一個,都不會覺得有問題。
這跟上一節是同一件事再往前一格。上一節是燈亮了,我沒有在看。這一節是燈從頭到尾沒有被點亮過,而報告上寫著它是綠的。
效能表格我作廢過兩次,兩次都是自己造成的。
第一次是測試工具本身變成了負載。它結束伺服器的方式是先送終止訊號,十秒沒退就強制砍掉,而被強制砍掉的伺服器不會執行收尾,那些拿來製造輸出的迴圈就留在那裡沒人管。孤兒累積、機器變慢、變慢又害下一次收尾逾時、逾時又觸發強制砍掉。跑完五輪,一台四核機器上留著 26 個還在跑的產生器,load average 33。這不是 33%;Linux 會把正在執行、等待 CPU,或處於不可中斷 I/O 的工作一起算進來,而四核心同時只能執行四個 CPU 工作,33 代表當時有大量工作正在排隊或卡在 I/O。
真正要命的是那份數字看起來完全像個結果:C 版的終端輸出從每秒 12.2 MB 一路散到 71.5 MB,session 速率掉了一半。我要是沒去看機器狀態,那張表就會被當成「C 版在高負載下不穩定」發表出去。第二次更單純,我在一個瀏覽器壓力測試還跑著的時候量了另一份表。
兩次的形狀是同一個:一個把量測者自己算進去的量測。所以現在那支工具每次測完會自己掃一遍有沒有留下孤兒,啟動時機器上已經有殘留的話就直接拒絕跑。
但有一件事我沒有宣稱。加了自動清掃之後孤兒還是會出現,大約每輪四個,而且只在機器已經忙過一陣子之後才有,單獨跑同樣兩項量測從來不會。所以強制砍掉不是唯一的來源,第二條到現在還沒查出來。 我把它寫成一個公開的未解問題,而不是留給讀的人自己踩。
先講數字。這次公開量測在同一台閒置的四核機器上進行,兩顆 Release binary 交錯跑五輪,以下取中位數。從啟動到回報開始監聽,C 是 4.3 毫秒,Rust 是 2.7 毫秒,兩邊的區間沒有重疊。終端輸出從每秒 76.7 MB 到 92.3 MB;每秒能開關的 session 從 163 到 195。完整方法與各輪區間放在公開 repo 的 rust/PARITY.zh-TW.md。
記憶體是 C 贏,而且贏很多。 量測時先讀 ttyd 程序本身常駐在實體記憶體裡的大小,再同時維持 25 場閒置連線,以增加量除以 25。這不包含各場啟動的 shell 子程序;只看 ttyd 自己增加的平均成本,C 每場是 17.3 KB,Rust 是 82.7 KB,差 4.8 倍。
這件事我接受,理由不是它不重要,是它在我的規模下不會咬到我。一個人同時開幾場,跟他瀏覽器開幾個分頁是正相關的,而那個數字不會大到讓 4.8 倍變成問題。如果哪天它會,那就是我該回頭處理它的時候。
再講一個我自己更在意的答案。我現在日常都用 Rust 版。
這不是因為跑分好看,是因為那才是真的在測它。一套只有測試在跑的東西,跟一套每天被真正使用的東西,可靠度不是同一件事。
而我心裡的驗收條件很簡單,簡單到有點粗暴:同樣的情境,如果我遇到不順,切回 C 版就解決了,那就是我改寫的問題。 到目前為止還沒有發生。
但我要誠實補一句。有些問題要跑很久才會現形,而我的使用情境是斷斷續續的短時段,一場審查跑完就關掉。所以「到目前為止沒事」這句話,我的樣本形狀是偏的。 上面那個沒查出來的累積現象就是一個提醒:它只在機器忙過一陣子之後才出現。
重寫版的版號從 2.0.0 起算,目前因 macOS 編譯修正已到 2.0.1。當時我從 1.7.7 跳到 2.0.0,理由有兩個。第一個是我不想把整個實作替換掉,卻只升成 1.8.0,讓使用者以為這只是一次普通的功能增量;這是我選擇怎麼傳達變更幅度。真正構成破壞性變更的是第二個:我沒有移植 Windows 支援。原版透過 ConPTY 支援 Windows,我只做了 Unix 那條路,為的是讓程序群組、控制終端、訊號和結束碼的行為跟原版逐項對齊。少一個原本支援的平台,在版本語意上就該升大版號。這兩個理由我也寫在 rust/Cargo.toml 的註解跟 README 的 Versioning 一節裡,因為它是使用者升上來第一個會問的事。
網頁終端有一個硬傷,跟重寫沒有關係,是形態決定的。
PTY 提供的是終端輸入輸出的位元組流,不是檔案上傳介面。 目前這條 ttyd/xterm.js 路徑沒有處理瀏覽器拖放檔案,也不會憑空把一張圖片變成容器裡的檔案,所以不能像一般網頁表單那樣,把圖片直接拖進終端交給 AI。
我打算的補法很直觀:另外開一條路上傳,把檔案寫進那一場的持久化目錄,回傳容器內的路徑,然後把路徑複製到剪貼簿,人自己貼進去,讓 AI 自己去讀那個檔案。
這條路要先拆掉兩道我自己立的閘:後端有一條「寫入類請求一律只收 JSON」的規則,還有 Nginx 的請求大小上限。拆的時候要補上副檔名白名單、大小上限和路徑穿越防護,因為這會是控制平面第一條直接接收瀏覽器檔案內容的 HTTP 路徑。
這一段後來做了,而且做的時候發現一件事:拆掉自己立的閘,代價是要立回更多道。補回去的那四道明天講。
老實說,跑 Code Review 到現在還沒真的需要過貼圖。之所以還是把它排進去,是因為看得到另一種未來:這套東西如果哪天被拿去做別的事情,那個需求會馬上出現。
今天其實不是在講一個終端程式,是在講怎麼確認自己沒把事情弄壞:
我對 Rust 不夠熟,反而迫使我不能靠熟悉感驗收,只能把證據鏈做完整。 不熟不是驗證方法,它只是逼我把每一個「可以接受」的理由從腦中的直覺,搬成別人也能檢查的東西。
最後那一條是我這次真正學到的東西。前面幾天我一直在清理「前提消失但論證還在」的痕跡,那種至少讀起來會怪。這一種不會,它安安穩穩地待在註解裡,說著一件它其實沒有做到的事。而它最極端的形式,是一份報告上寫著某個發現來自某支測試,而那支測試從來沒有被執行過。
明天講我沒有做的那些、為什麼決定不做,以及上面那條做完之後補回去的四道閘。