
跟朋友說好「遲到超過二十分鐘你們先走」。朋友點頭。結果遲到了半小時,朋友還在原地——因為說出口的條件,從來沒有被連進任何判斷流程。旗標存在,但世界繼續照舊運轉。
這支自動化升級腳本就是這個狀況。
技術環境
自動化升級腳本,Node.js 環境,採同步順序執行:安裝階段完成後寫入旗標變數,建構迴圈接在安裝段之後跑。旗標的寫入與讀取沒有共用任何條件判斷機制——兩個階段彼此獨立存在於同一個執行流程中,但沒有任何語法結構將它們實際連接。這個問題與語言框架無關;任何多階段自動化腳本只要旗標寫入點和讀取點沒有同時設計,都會複現相同行為。
現象
腳本跑完安裝之後,把一個旗標變數設為已完成。設計意圖很清楚:後面的建構迴圈看到這個旗標,就跳過,不用重跑一遍。邏輯上完整,行為上卻沒有。建構迴圈照跑,沒有任何報錯,exit code 是 0,log 一片平靜,只是多做了一些不需要做的事。
這種錯誤最難被抓到,因為它不失敗。
錯誤傳染鏈(時序)
Upgrade Script
|
|── [安裝階段] ─────────────────────────────────────────────>
| |
| installFlag = 'done' ✓
|
|── [建構迴圈階段] ─────────────────────────────────────────>
| |
| for (const item of buildItems) {
| // ← 沒有讀取 installFlag ✗
| build(item); // 照跑
| }
|
|── exit 0 ──────────────────────────────────────────────────
設計預期:installFlag = 'done' → 建構迴圈跳過
實際行為:installFlag = 'done',建構迴圈照跑
關鍵節點在建構迴圈的入口——旗標寫對了,但迴圈的第一行從來沒有去讀它,兩個階段在執行流程上是鄰居,在邏輯上卻各自為政。
容易誤判的原因
第一時間看 log,什麼都正常。腳本完成、狀態碼乾淨、輸出沒有異常欄位。唯一的線索是執行時間比預期長,但那個差距細微到很容易被忽略或解釋成環境差異。
旗標的值是對的。設值那段程式碼是對的。問題在另一端,在那個沒有加條件判斷的 for 迴圈。兩段程式碼各自正確,合在一起卻沒有實際連接。
這類問題在有多個觸發路徑的系統裡很常見——一條路徑設旗標,另一條路徑本來應該讀旗標,但「應該」沒有被寫進程式碼。旗標變成一個只能被設、無法被讀的狀態,存在的意義只剩下自我完整。
分界點
問題就在建構迴圈進入前的那一行——或者更準確說,那一行不存在。沒有 if (flag === 'done') continue,沒有 if (alreadyInstalled) return,沒有任何形式的條件跳出。旗標和迴圈之間有一道邏輯上應該存在的橋,但橋沒有蓋。
修正只需要在 for 迴圈前加上一個條件判斷:旗標已完成就整個跳過。一行程式碼,讓這個旗標終於有了實際意義。
Code 對照:修法前後
修法前(旗標有設,無人在讀)
// 安裝階段
function runInstall(items) {
items.forEach(item => install(item));
installFlag = 'done'; // ← 設了,但沒有任何地方去檢查它
}
// 建構迴圈(後續執行)
for (const item of buildItems) {
// ← 這裡沒有 if (installFlag === 'done') continue
build(item); // 照跑,不管旗標是什麼狀態
}
修法後(一行讀取讓旗標有了實際意義)
// 安裝階段(不變)
function runInstall(items) {
items.forEach(item => install(item));
installFlag = 'done';
}
// 建構迴圈(加上條件讀取)
for (const item of buildItems) {
if (installFlag === 'done') continue; // ← 旗標終於有了讀取點
build(item);
}
該被隔離的側效應類型
- 重複建構產物:迴圈多跑一次會覆蓋或複寫已完成的建構輸出,可能破壞產物的一致性。
- 檔案系統寫入:同一批文件被寫兩次,若中間有版本差異,後次寫入會靜默蓋掉前次結果。
- 部署觸發器:建構完成後若有自動部署鉤子(deploy hook),多餘的建構會觸發不必要的部署流程。
- 快取失效:每次建構完成後若自動清快取,多餘的迴圈會讓快取被清兩次,浪費後端資源。
- 資源消耗計量:CI/CD 系統通常以執行時間計費;多餘的迴圈會讓帳單數字比實際工作量高,且難以追蹤原因。
- Log 污染:每次建構都會留下 log 條目;重複建構讓 log 量加倍,卻沒有對應的錯誤訊號,讓後續審查更難找出真正的問題。
- 執行時間異常:腳本跑比預期久,但 exit code 是 0;這個細微的時間差異是唯一的線索,但最容易被歸因成環境差異而被忽略。
- 狀態機漂移:旗標的設計意圖是管理執行狀態,但讀取點不存在意味著這個狀態機只有輸入、沒有輸出——旗標設置後整個系統的行為不受它影響。
如果一個旗標的設置沒有對應的讀取點,它就不是狀態管理,只是一個值被寫進變數、然後被遺忘在那裡。設計旗標前先問:讀取點在哪裡?
確認方式
最直接的驗證:在旗標已設為完成的狀態下重跑腳本,看建構迴圈是否還執行。如果還跑,問題確認。如果不跑,再確認一次旗標的初始狀態是否乾淨。不需要更複雜的 instrumentation,這個 check 已經夠明確。
留給未來的話
旗標設完之後,值得問一句:誰在讀這個旗標?在哪裡讀?如果答案不能立刻從程式碼裡指出來,那個旗標可能只是一個被寫入、永遠沒被讀取的值。設計旗標的時候,讀取點和寫入點應該是同時存在的,不是事後補的。
— 邱柏宇
延伸閱讀
The Flag Was Set. Nobody Was Listening.
You tell your friends: “If I’m more than twenty minutes late, just leave without me.” They nod. You show up thirty minutes late. They’re still standing there — because what you said and the decision process governing whether they leave were never connected. The flag existed. Nothing was listening to it.
That’s exactly what happened with this upgrade script.
Technical Environment
Node.js automation upgrade script running in synchronous sequential order: an install phase completes and writes a flag variable, followed immediately by a build loop phase. The flag write and the build loop share no conditional gate — both phases exist independently within the same execution flow without any syntactic structure connecting them. This issue is framework-agnostic; any multi-phase automation script where the flag write point and read point are not co-designed will reproduce the same behavior.
What Was Observed
The automation script finishes installation and sets a flag variable to “done.” The intent is clear: the build loop downstream should see that flag and skip — no need to re-run. The logic is complete on paper. In practice, the build loop runs anyway. No errors thrown. Exit code is 0. The log looks clean. It just does a little extra work it didn’t need to do.
This is the hardest class of bug to catch, because it never fails.
Error Propagation Sequence
Upgrade Script
|
|── [Install Phase] ───────────────────────────────────────>
| |
| installFlag = 'done' ✓
|
|── [Build Loop Phase] ────────────────────────────────────>
| |
| for (const item of buildItems) {
| // ← no installFlag check ✗
| build(item); // runs anyway
| }
|
|── exit 0 ─────────────────────────────────────────────────
Designed intent: installFlag = 'done' → build loop skips
Actual behavior: installFlag = 'done', build loop runs regardless
The critical gap is at the build loop’s entry point — the flag was written correctly, but the first line of the loop never reads it; two phases are neighbors in the execution flow, yet logically independent of each other.
Why It Goes Unnoticed
Nothing in the log surface indicates a problem. The script completes, the status code is clean, no unexpected output fields. The only hint is a slightly longer execution time — subtle enough to be dismissed as environment variance.
The flag value is correct. The code that sets the flag is correct. The issue is on the other side: the for loop that was written without a condition to check the flag. Both halves are individually sound. Together, they don’t connect. The flag can be written but never read — a state that exists only to satisfy itself.
Where the Break Is
The missing piece is the line before the build loop enters — or more precisely, the line that was never written. No if (flag === 'done') continue. No early return. No skip condition of any form. There’s a logical bridge that should exist between the flag and the loop. The bridge was never built.
The fix is one line: a conditional check at the top of the for loop. If the flag is already marked complete, skip the entire block. One line gives the flag its first real consequence.
Code Diff: Before and After
Before (flag set, nobody reading it)
// Install phase
function runInstall(items) {
items.forEach(item => install(item));
installFlag = 'done'; // ← set here, but nothing ever checks it
}
// Build loop (executes afterward)
for (const item of buildItems) {
// ← no: if (installFlag === 'done') continue
build(item); // runs unconditionally regardless of flag state
}
After (one read point gives the flag its first real consequence)
// Install phase (unchanged)
function runInstall(items) {
items.forEach(item => install(item));
installFlag = 'done';
}
// Build loop (with conditional read)
for (const item of buildItems) {
if (installFlag === 'done') continue; // ← flag finally has a reader
build(item);
}
Side Effects That Should Be Isolated
- Duplicate build artifacts: Running the loop twice overwrites or re-creates already-completed outputs, potentially breaking artifact consistency.
- File system writes: The same set of files gets written twice; if anything changes between runs, the second write silently overwrites the first.
- Deployment triggers: If a deploy hook fires on build completion, a redundant build loop iteration triggers an unnecessary deployment pipeline.
- Cache invalidation: If cache is auto-cleared after each build, the redundant loop clears it twice, wasting backend resources with no observable error signal.
- Resource billing: CI/CD systems typically charge by execution time; redundant iterations inflate the bill beyond what the actual work requires, and the cause is hard to trace.
- Log pollution: Each build run produces log entries; a duplicate run doubles the log volume with no corresponding error signals, making post-incident review harder.
- Execution time anomaly: The script takes longer than expected but exits with code 0; this subtle timing gap is the only diagnostic hint and the easiest to attribute to environment variance.
- State machine drift: The flag’s design intent is to manage execution state, but without a read point the state machine only has inputs with no outputs — the flag’s value never influences system behavior after it is set.
If a flag has no corresponding read point, it is not state management — it is a value written into a variable and left there. Before designing a flag, ask: where is the read point?
How to Confirm It
Re-run the script with the flag already set to done. Watch whether the build loop executes. If it does, the problem is confirmed. If it doesn’t, verify that the flag’s initial state is clean before the next run. No elaborate instrumentation needed — this single check is sufficient.
One Thing Worth Remembering
After writing a flag, it’s worth asking: who reads this flag, and where? If that answer can’t be pointed to immediately in the codebase, the flag may be a value that gets written and never consumed. The read point and the write point should be designed at the same time — not patched in later.
— 邱柏宇
Related Posts
https://justfly.idv.tw/s/fBfa4xE