Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/fork/DECISIONS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# 維護決策

## 2026-08-23:PR/issue 一律 `--state all`,並在 `dev` 上選擇性引用

**決定**:修正 2026-08-22 那條「上游的 PR 不是本 fork 的審查單位」。審查單位仍是 `main` 上的
release commit,但增加兩條:

1. 查 PR 與 issue **一律用 `--state all`**。未合併就關閉的 PR 永遠不會經由 commit 路徑抵達,
而那正是「上游拒收、但可能對本 fork 有價值」的一類。
Comment on lines +8 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the all-state rule to the scheduled issue collector

The new rule is not implemented by the scheduled checker: tools/check-upstream-updates.ts:150 still invokes gh issue list --state open, although gh issue list --help confirms that all is supported and open excludes closed issues. Consequently, a tracked platform issue that opens and closes between weekly runs is omitted, and once a higher-numbered issue advances reviewed_issue_through, it will never be triaged. Update the collector and its focused tests to use --state all rather than changing only the documented/manual review process.

Useful? React with 👍 / 👎.

2. 已合併但只在 `dev` 的修正,若**本 fork 現在就會痛**,就選擇性引用,不等 release。

**理由**:`dev` 目前領先 `main` 69 個 commit,實測 `#2398` 的 merge commit 在 `dev` 不在 `main`
——「合併後會隨 release 進 `main`」在時間上可能是好幾週。本輪依此引用了兩支「讀取沒有上限」的
修正(`#2395`、`#2398` 前半),兩者的缺陷都在本 fork 的程式碼裡實查確認過。

**維持不變**:不逐筆採用未合併的 open PR。那是提案,不是上游已接受的變更;採用等於接手維護一份
還會變動的補丁。逐項證據與觸發條件記在 [`UPSTREAM.md`](UPSTREAM.md)。

## 2026-08-22:上游的 PR 與分支不是本 fork 的審查單位,issue 只追 platform

**決定**:不逐筆評估上游的 open PR(43 個,全部 base 在 `dev`)與分支(71 個,多為那些 PR 的
Expand Down
49 changes: 49 additions & 0 deletions docs/fork/UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,52 @@ upstream/main...upstream/dev` 回報 **25 / 58**——`dev` 已經領先 `main`
安全性,以及本檔 issue 表裡已登記為「等上游修」的項目。
3. 挑中的先確認它只依賴 `main` 已有的檔案(`git cat-file -e upstream/main:<file>`),
再 cherry-pick,並記下未來同步時會衝突的檔案。

## 2026-08-23(第二輪):`--state all` 補查,並引用兩支「無上限讀取」修正

### 先修一個查法上的缺口

上一輪查 PR/issue 用的是 `--state open`。那看不到**未合併就關閉**的項目,而那正是「上游拒收、
但可能對本 fork 有價值」的一類——已合併的項目遲早會經由 commit 路徑抵達,被關掉的永遠不會。
本輪起一律 `--state all`。

用 `--state all` 重查水位(PR `#2383`/issue `#2379`)之後的增量:**33 個 PR、55 個 issue**。
其中 12 個 PR 已合併,但**合併進的是 `dev` 不是 `main`**(實測:`#2398` 的 merge commit
`383279cd2` 在 `upstream/dev`、不在 `upstream/main`;`dev` 目前領先 `main` 69 個 commit)。
本 fork 的取用點是 `main`,所以這 12 筆**還沒**被 commit 水位涵蓋——判準仍是上一節那條:
**`dev` 上有沒有本 fork 現在就會痛的修正**。

### 已引用(兩支,都是「讀取沒有上限」)

| 來源 | 本 fork 的實查證據 | 移植內容 |
| --- | --- | --- |
| [PR #2395](https://github.com/lidge-jun/opencodex/pull/2395) `fix(usage): bound incremental append reads`(`3611850c5`) | `src/usage/log.ts` 的 `readUsageEntriesIncrementally()` 只擋「檔案縮小」,沒有擋「一次 append 太多」。兩次輪詢之間灌進來的位元組不論多大,都會走增量路徑整批讀進來並逐行解析——**繞過 `maxReadBytes` 這個唯一的記憶體上限,而且是在帳本長最快的時候繞過**。 | 4 行守衛:`size - retained.coveredThroughBytes > maxReadBytes` 時回 `null`,交還給有上限的 full-tail reader。 |
| [PR #2398](https://github.com/lidge-jun/opencodex/pull/2398) `fix(responses): bound upstream error body reads`(`383279cd2`)的**前半** | `src/lib/bounded-body.ts` 的兩個進入點(`readBoundedResponseBytes:118`、`readBoundedResponseBody:211`)在 signal 已經 aborted 時**先 throw 再說**,此時還沒有 reader 掛上去,原始 body 因此沒有被結束——fetch 背後的 stream 會把一個被拒絕的 read 留著。 | 新增 `cancelBodyWithoutWaiting()`,兩個進入點在 throw 之前先結束 body。 |

**驗證**:兩支各補一條測試(取自上游同一個 PR 的測試,改寫成本 fork 的檔案結構)。把 src 改動
stash 掉重跑,兩條都紅(`bounded-body` 1 fail、`api-usage` 1 fail),確認測試有牙齒;改動放回
全綠(`bounded-body` 27 pass、`api-usage` 25 pass)。

**不引用 #2398 的後半**(`readDisplaySafeErrorText`,`src/server/responses/core.ts` +65 行):那是
錯誤路徑的重構,依賴 `dev` 上的 `bounded-body` 新介面,硬移植等於自行改寫。**觸發條件**:隨
release 進 `main`,或本線出現「上游錯誤內容被原樣顯示」的實例。

### 其餘 31 個 PR

- **已合併進 `dev` 的另外 10 筆**:`devlog:` 工作紀錄與 `fix(zcode)`/`fix(auth)`/`fix(gui)` 等,
逐條看過主旨與檔案,都不是本 fork 現在會痛的路徑;等 release。
- **open/closed-未合併的 21 筆**:`fix(xai)`、`fix(codex)`、`fix(catalog)`、`feat(test)` 這類,
base 都在 `dev`,且改的是本 fork 沒有啟用的 provider 路徑或上游自己的測試工具鏈。它們是**提案**,
不是上游已接受的變更;採用未合併的提案等於接手維護一份上游還沒定案的補丁。**觸發條件**:合併
進 `dev` 且屬「本 fork 現在會痛」那一類,或隨 release 進 `main`。

### 55 個 issue

以「會不會改變本 fork 要驗什麼」為判準逐條掃過標題。多數是 provider 目錄、xAI/Kiro/OpenRouter
的路由行為與上游自己的 App 問題。沒有一條指向本 fork 已知的 Windows 行為缺口——`#2292`(Windows
model picker)那條已於本檔上一節引用 `a3bbcdb0` 解決。

### 水位

- commit:`6ae83b1`(`6ae83b1..upstream/main` 仍為 0)
- PR:**#2433**、issue:**#2434**(首次以 `--state all` 查過)
24 changes: 20 additions & 4 deletions src/lib/bounded-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ function cancelWithoutWaiting(reader: ReadableStreamDefaultReader<Uint8Array>, r
}
}

function cancelBodyWithoutWaiting(body: ReadableStream<Uint8Array>, reason?: unknown): void {
// A signal can already be aborted before a reader is attached. Settle the original
// body anyway, or a fetch-backed stream keeps a rejected read alive in that gap.
try {
void body.cancel(reason).catch(() => undefined);
} catch {
// A locked or non-conforming stream may throw synchronously from cancel().
}
}

/**
* Consume the original response body as raw bytes under a strict memory ceiling.
*
Expand All @@ -115,9 +125,12 @@ export async function readBoundedResponseBytes(
options: BoundedBytesOptions,
): Promise<BoundedBytesResult> {
const signal = options.signal;
if (signal?.aborted) throw signal.reason;

const body = response.body;
if (signal?.aborted) {
if (body) cancelBodyWithoutWaiting(body, signal.reason);
throw signal.reason;
}

if (!body) return { bytes: new Uint8Array(0), oversized: false };

const reader = body.getReader();
Expand Down Expand Up @@ -208,9 +221,12 @@ export async function readBoundedResponseBody(
options: BoundedBodyOptions = {},
): Promise<BoundedBodyResult> {
const signal = options.signal;
if (signal?.aborted) throw signal.reason;

const body = response.body;
if (signal?.aborted) {
if (body) cancelBodyWithoutWaiting(body, signal.reason);
throw signal.reason;
}

if (!body) {
return {
text: "",
Expand Down
5 changes: 5 additions & 0 deletions src/usage/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,11 @@ async function readUsageEntriesIncrementally(
// A shrink means truncation or replacement-in-place; the retained rows may no
// longer correspond to file contents, so refuse to extend them.
if (size < retained.coveredThroughBytes) return null;
// Retained-state reuse is only an optimization. A burst larger than the configured
// window must re-anchor through the bounded full-tail reader instead of reading and
// parsing every byte appended since the previous poll -- otherwise the one path that
// exists to cap memory is bypassed exactly when the ledger grows fastest.
if (size - retained.coveredThroughBytes > maxReadBytes) return null;
// Verify the retained REGION is unchanged before anything is reused. Identity keeps
// dev/ino/birthtime, and an append and an in-place rewrite both move mtime/ctime
// forward, so only the bytes themselves settle it.
Expand Down
34 changes: 34 additions & 0 deletions tests/api-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,40 @@ describe("GET /api/usage", () => {
}
});

test("an append burst larger than the byte window falls back to a bounded full read", async () => {
// Retained-state reuse is an optimization, not a second reader. Without the size
// guard a burst bigger than the window is read and parsed in full through the
// incremental path -- bypassing the only thing that caps memory here, and doing so
// exactly when the ledger is growing fastest. Taken from upstream #2395.
const now = Date.now();
const maxReadBytes = 512;
const row = (id: string): string => `${JSON.stringify({
requestId: id,
timestamp: now,
provider: "openai",
model: "gpt-5.5",
status: 200,
durationMs: 1,
usageStatus: "reported",
usage: { inputTokens: 1, outputTokens: 1 },
totalTokens: 2,
})}\n`;
const path = join(testDir, "usage.jsonl");
writeFileSync(path, row("seed"));

await usageLogModule.readUsageSnapshotForManagement(maxReadBytes);
const parsedBeforeBurst = usageReadCacheStatsForTests().parsedLines;
appendFileSync(path, Array.from({ length: 100 }, (_, index) => row(`burst-${index}`)).join(""));

const snapshot = await usageLogModule.readUsageSnapshotForManagement(maxReadBytes);
const stats = usageReadCacheStatsForTests();
expect(stats.fullReads).toBe(2);
expect(stats.tailReads).toBe(0);
expect(stats.parsedLines - parsedBeforeBurst).toBe(snapshot.entries.length);
expect(snapshot.entries.length).toBeLessThan(100);
expect(snapshot.entries.some(entry => entry.requestId === "burst-99")).toBe(true);
});

test("appends to an over-window ledger stay incremental and bounded", async () => {
const now = Date.now();
writeFixture(now);
Expand Down
25 changes: 25 additions & 0 deletions tests/bounded-body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,31 @@ describe("readBoundedResponseBody", () => {
}
});

test("an already-aborted signal still settles the original response body", async () => {
// The early return threw before a reader was ever attached, so a fetch-backed
// stream kept a rejected read alive in that gap. Taken from upstream #2398.
let cancelReason: unknown;
const body = new ReadableStream<Uint8Array>({
pull() { return new Promise<never>(() => {}); },
cancel(reason) { cancelReason = reason; },
}, { highWaterMark: 0 });
const response = new Response(body);
const controller = new AbortController();
const reason = { code: "already-stopped" };
controller.abort(reason);

let caught: unknown;
try {
await readBoundedResponseBody(response, { signal: controller.signal });
} catch (error) {
caught = error;
}
await Promise.resolve();

expect(caught).toBe(reason);
expect(cancelReason).toBe(reason);
});

test("parent abort wins when EOF settles in the same turn", async () => {
const parent = new AbortController();
const reason = new Error("same-turn cancel");
Expand Down
6 changes: 5 additions & 1 deletion tests/fork-hygiene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,11 @@ describe("upstream checker", () => {
expect(baseline.repo.endsWith("opencodex.git")).toBe(true);
expect(baseline.branch).toBe("main");
expect(baseline.reviewed_through).toHaveLength(40);
expect(baseline.reviewed_date).toBe("2026-08-22");
// Not a frozen value: this date moves every time the upstream review runs, and
// pinning it turns a completed review into a red test. What matters is that it
// is a real ISO date and that the decisions record carries it -- the test below
// checks the second half.
expect(baseline.reviewed_date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});

test("workflow is scheduled and fails on unreviewed commits", async () => {
Expand Down
6 changes: 3 additions & 3 deletions tools/upstream_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"repo": "https://github.com/lidge-jun/opencodex.git",
"branch": "main",
"reviewed_through": "6ae83b1f189c353935d4977bb01227484fbdb52b",
"reviewed_date": "2026-08-22",
"reviewed_issue_through": 2379,
"reviewed_pr_through": 2383
"reviewed_date": "2026-08-23",
"reviewed_issue_through": 2434,
"reviewed_pr_through": 2433
}
Loading