Skip to content

Commit b1d0f8a

Browse files
committed
Merge branch 'dev' into fix/764-winsw-env-and-liveness-probe
2 parents 048703e + ebd4cdf commit b1d0f8a

6 files changed

Lines changed: 236 additions & 73 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# 050 — windows-latest 플레이크 RCA
2+
3+
CI가 세 번 연속 빨간데 **매번 다른 테스트**가 죽었다. 이런 모양이면 보통 원인이
4+
테스트가 아니라 환경이다. 기록해두는 이유는, 다음에 이 색깔을 보는 사람이 같은
5+
진단을 처음부터 다시 하지 않도록 하기 위해서다.
6+
7+
## 관측
8+
9+
| 커밋 | 죽은 테스트 | 시간 |
10+
|---|---|---|
11+
| `bf9bc1ac8` | Windows tray packaging — detached tray host가 listen 소켓을 안 물고 뜨는지 ||
12+
| `f2b61ee7e` | `GET /api/github/star` ×2 | 5015ms, 5001ms |
13+
| `6e1cdb7de` | server local API auth — pool retry 인가 안 함 ×2 | 5353ms, 412ms |
14+
15+
`bf9bc1ac8`**내 푸시 이전** 커밋이다. 즉 이 라운드가 만든 문제가 아니다.
16+
셋 다 로컬에서 통과하고, 셋 다 해당 파일이 그 푸시의 diff에 없다.
17+
18+
공통점은 5초다. Bun의 테스트 예산이 5초이고, 세 건 모두 **러너가 통제하는 무언가를
19+
기다리다** 그 예산을 넘겼다. 다만 기다리는 대상은 서로 다르다 — 하나의 원인으로
20+
묶으려다 틀리는 것보다 셋을 따로 보는 게 맞았다.
21+
22+
## 원인 1 — sidebar: 진짜 `gh`를 띄운다
23+
24+
`star-state.ts:55``spawnGh()`가 사용자의 실제 `gh` 프로세스를 띄운다.
25+
`AUTH_TIMEOUT_MS = 5_000`.
26+
27+
여기서 중요한 건, **이미 한 번 고쳐진 적이 있다는 것**이다. `0af17fbfd`
28+
Windows `.cmd` shim 해석을 `commandInvocation`으로 돌렸고, 코드 주석이 그 사실을
29+
명시한다:
30+
31+
> On Windows `gh` is a `.cmd` shim ... which is how these sidebar tests turned into
32+
> 5s timeouts on windows-latest while passing everywhere else.
33+
34+
그런데 `f2b61ee7e``0af17fbfd` **이후**인데도 같은 자리에서 죽었다. 바이너리를
35+
정확히 찾아주는 것과, 부하 걸린 러너에서 그 프로세스가 빨리 끝나는 것은 다른
36+
문제다. 첫 수정은 필요했지만 충분하지 않았다.
37+
38+
진짜 문제는 **라우트 테스트가 외부 바이너리를 띄운다는 사실 자체**다. `gh`의 설치
39+
여부, 인증 헬퍼, Windows shim은 전부 라우트 계약 밖이다.
40+
41+
수정: `star-state.ts`에 이미 있던 `StarDeps`의 주입 가능한 `runGh`
42+
`setStarDepsForTests()`로 선택한다. 테스트는 자기가 실제로 주장하는 것 —
43+
라우트 도달 가능성, 응답 형태, 그리고 `gh` 출력·토큰·계정 식별자가 절대
44+
직렬화되지 않는다는 것 — 을 결정적인 fake로 검증한다. 5초가 0.25ms가 됐다.
45+
프로덕션은 진짜 러너를 그대로 쓴다.
46+
47+
## 원인 2 — server auth: 한 테스트가 하니스를 네 번 띄운다
48+
49+
malformed-detail 케이스 네 개가 각각 프록시/업스트림 하니스를 새로 세웠다.
50+
Windows에서는 그 기동 비용만으로 예산이 찼고, 마지막 요청이 아직 날아가는 중에
51+
다음 테스트가 시작돼 전역 `fetch`를 두고 경합했다. 412ms짜리 실패가 그 흔적이다.
52+
53+
수정: 하니스 하나를 네 케이스가 공유한다. 각 케이스는 여전히 자기 원본 400과
54+
`acct-pool-a` 단일 dispatch를 증명한다.
55+
56+
## 원인 3 — tray: 안 고쳤다
57+
58+
`windows-tray.test.ts:247`이 PowerShell을 띄우고, 거기서 Bun 자식을 띄우고,
59+
같은 포트에 다시 bind한다. **자식이 listen 소켓을 물려받지 않는다는 것을 증명하는
60+
게 이 테스트의 존재 이유다.**
61+
62+
결정적으로 만들려면 `src/tray/windows.ts:464`에 프로세스 기동 seam이 필요하다.
63+
테스트에서만 흉내내면 증명이 사라진다 — 소켓 상속은 진짜 프로세스를 띄워야만
64+
관측되는 성질이다. 그래서 손대지 않았다.
65+
66+
**이건 미해결이다.** 초록으로 만들 수 있었지만 그렇게 하면 테스트가 지키던 것을
67+
버리는 것이다.
68+
69+
## 하지 않은 것
70+
71+
타임아웃을 올리지 않았다. 테스트를 skip하지 않았다. assertion을 지우지 않았다.
72+
셋 다 빨간색을 없애지만 신뢰성 신호를 침묵으로 바꾼다. `020`에서 무력한 테스트
73+
세 건을 지적해놓고 여기서 같은 짓을 하면 앞뒤가 안 맞는다.
74+
75+
## 인접 작업
76+
77+
PR #801(luvs01)이 Windows PowerShell 프로세스 열거 플레이크를 다룬다 — CIM
78+
열거가 첫 시도에 빈 결과를 주면 한 번 재시도. 우리가 고친 것과 다른 지점이고
79+
충돌하지 않는다.
80+
81+
PR #805(Wibias)가 #764`--native` 잔여를 닫는다. 지난 라운드에서 내가 열어둔
82+
바로 그 절반이다.

src/adapters/openai-chat.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
697697
const pendingToolCalls: PendingToolCall[] = [];
698698
let toolCallSeq = 0;
699699
const flushToolCalls = function* (): Generator<AdapterEvent> {
700+
// Do not treat flushed tool calls as user-facing output for the finish-less EOF
701+
// fallback — incomplete tool args must stay on the truncation path.
700702
for (const call of pendingToolCalls) {
701703
if (!call.id) call.id = `call_${++toolCallSeq}`;
702704
yield { type: "tool_call_start", id: call.id, name: call.name };
@@ -711,6 +713,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
711713
// explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
712714
// OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
713715
let finishReason: string | undefined;
716+
// Only answer text enables the finish-less EOF fallback. Reasoning-only streams can be
717+
// suppressed by hideThinkingSummary and must not complete as empty successful turns.
718+
let sawUserFacingOutput = false;
714719

715720
// Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
716721
// a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
@@ -780,6 +785,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
780785
yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
781786
}
782787
if (typeof delta.content === "string" && delta.content.length > 0) {
788+
sawUserFacingOutput = true;
783789
yield { type: "text_delta", text: delta.content };
784790
}
785791

@@ -837,10 +843,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
837843
if (buffer.length > 0) {
838844
if ((yield* handleDataLine(buffer)) === "terminate") return;
839845
}
840-
// Reader EOF. A graceful close shows at least one terminal signal: `[DONE]` (returns above),
841-
// a non-null finish_reason (sawFinish), or a trailing usage chunk (providers emit usage only
842-
// at end-of-generation). If NONE of those were seen, the stream was cut mid-flight — fail
843-
// closed so the bridge emits a classified response.failed rather than a silent truncation.
846+
// Reader EOF. Prefer failing closed before flushing pending tool calls so the bridge
847+
// never sees a fabricated tool_call_end on a truncated mid-assembly stream.
844848
//
845849
// Checked BEFORE flushToolCalls(), because that helper emits tool_call_end and there is no
846850
// taking it back: a half-assembled argument string would reach the client as a completed
@@ -856,16 +860,19 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
856860
yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
857861
return;
858862
}
859-
yield* flushToolCalls();
860-
if (!sawFinish && pendingUsage === undefined) {
863+
// Finish-less EOF is only safe when answer text was emitted. Reasoning-only / usage-only
864+
// truncations must stay on the error path (hideThinkingSummary can suppress reasoning).
865+
// Trailing usage alone is not a terminal signal for this adapter (#735 / restore #773).
866+
if (!sawFinish && !sawUserFacingOutput) {
861867
debugProviderDiagnostic("openai-chat", "stream-truncated", {
862868
finishReason: finishReason ?? null,
863-
hadUsage: false,
869+
hadUsage: pendingUsage !== undefined,
864870
});
865871
yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
866872
return;
867873
}
868-
// Graceful close that omitted [DONE] but delivered finish_reason and/or final usage.
874+
yield* flushToolCalls();
875+
// Graceful close that omitted [DONE] but delivered finish_reason and/or answer text.
869876
const stopReason = finishReason === "length"
870877
? "max_tokens"
871878
: finishReason === "content_filter"

src/github/star-state.ts

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -79,19 +79,8 @@ async function spawnGh(args: string[], timeoutMs: number): Promise<{ status: num
7979
}
8080
}
8181

82-
const defaultDeps: StarDeps = { runGh: spawnGh, nowMs: () => Date.now() };
83-
/** Test-only override so route tests never spawn a real `gh` (Windows CI hangs at AUTH_TIMEOUT). */
84-
let depsOverride: StarDeps | null = null;
85-
86-
function activeDeps(deps?: StarDeps): StarDeps {
87-
return deps ?? depsOverride ?? defaultDeps;
88-
}
89-
90-
/** Swap the default `gh` runner for tests; pass `null` to restore production deps. */
91-
export function setStarDepsForTests(deps: StarDeps | null): void {
92-
depsOverride = deps;
93-
invalidateStarStatusCache();
94-
}
82+
const productionDeps: StarDeps = { runGh: spawnGh, nowMs: () => Date.now() };
83+
let defaultDeps = productionDeps;
9584

9685
let cached: { timestamp: number; state: StarState } | null = null;
9786
/** Coalesces concurrent probes so parallel sidebar polls share one `gh` run. */
@@ -109,11 +98,10 @@ let generation = 0;
10998
* starred and 404 when not, so a non-zero exit is only meaningful once we know
11099
* the CLI is authenticated — hence the auth check first.
111100
*/
112-
export async function probeStarState(deps?: StarDeps): Promise<StarState> {
113-
const d = activeDeps(deps);
114-
const auth = await d.runGh(["auth", "status", "--hostname", GH_HOSTNAME], AUTH_TIMEOUT_MS);
101+
export async function probeStarState(deps: StarDeps = defaultDeps): Promise<StarState> {
102+
const auth = await deps.runGh(["auth", "status", "--hostname", GH_HOSTNAME], AUTH_TIMEOUT_MS);
115103
if (!auth || auth.status !== 0) return "unauthenticated";
116-
const starred = await d.runGh(
104+
const starred = await deps.runGh(
117105
["api", "--hostname", GH_HOSTNAME, `/user/starred/${STAR_REPO}`],
118106
API_TIMEOUT_MS,
119107
);
@@ -122,9 +110,8 @@ export async function probeStarState(deps?: StarDeps): Promise<StarState> {
122110
}
123111

124112
/** Cached star state; `gh` is only spawned when the cache is cold or expired. */
125-
export async function getStarStatus(deps?: StarDeps): Promise<StarStatus> {
126-
const d = activeDeps(deps);
127-
const now = d.nowMs();
113+
export async function getStarStatus(deps: StarDeps = defaultDeps): Promise<StarStatus> {
114+
const now = deps.nowMs();
128115
if (cached && now - cached.timestamp < CACHE_TTL_MS) {
129116
return { state: cached.state, repo: STAR_REPO, url: STAR_REPO_URL };
130117
}
@@ -137,7 +124,7 @@ export async function getStarStatus(deps?: StarDeps): Promise<StarStatus> {
137124
// The slot is cleared inside the same continuation that commits the cache. Using
138125
// `.finally()` for that defers it by a microtask, which leaves a window where the
139126
// next caller awaits an already-settled probe instead of starting a fresh one.
140-
const probe = probeStarState(d).then(
127+
const probe = probeStarState(deps).then(
141128
state => {
142129
if (inflight === probe) inflight = null;
143130
// A write landed while this read was in flight — its result is authoritative.
@@ -162,6 +149,17 @@ export function invalidateStarStatusCache(): void {
162149
inflight = null;
163150
}
164151

152+
/**
153+
* Route tests must not launch the user's `gh` executable: its installation,
154+
* authentication helper, and Windows shim are all outside the route contract.
155+
* Production keeps the real runner; tests install an explicit deterministic
156+
* dependency and must reset it afterwards.
157+
*/
158+
export function setStarDepsForTests(deps: StarDeps | null): void {
159+
defaultDeps = deps ?? productionDeps;
160+
invalidateStarStatusCache();
161+
}
162+
165163
/**
166164
* Star the repository through the user's `gh` login. Returns the resulting
167165
* state so the caller does not need a second round trip; an unauthenticated
@@ -173,20 +171,19 @@ export function invalidateStarStatusCache(): void {
173171
* management API.
174172
*/
175173
export async function starRepository(
176-
deps?: StarDeps,
174+
deps: StarDeps = defaultDeps,
177175
): Promise<{ ok: boolean; status: StarStatus; code?: StarErrorCode }> {
178-
const d = activeDeps(deps);
179-
const auth = await d.runGh(["auth", "status", "--hostname", GH_HOSTNAME], AUTH_TIMEOUT_MS);
176+
const auth = await deps.runGh(["auth", "status", "--hostname", GH_HOSTNAME], AUTH_TIMEOUT_MS);
180177
if (!auth || auth.status !== 0) {
181178
generation += 1;
182-
cached = { timestamp: d.nowMs(), state: "unauthenticated" };
179+
cached = { timestamp: deps.nowMs(), state: "unauthenticated" };
183180
return {
184181
ok: false,
185182
status: { state: "unauthenticated", repo: STAR_REPO, url: STAR_REPO_URL },
186183
code: "gh_unavailable",
187184
};
188185
}
189-
const result = await d.runGh(
186+
const result = await deps.runGh(
190187
["api", "--hostname", GH_HOSTNAME, "-X", "PUT", `/user/starred/${STAR_REPO}`],
191188
API_TIMEOUT_MS,
192189
);
@@ -201,6 +198,6 @@ export async function starRepository(
201198
// Authoritative: this call just starred the repo. Bumping the generation makes any
202199
// read that is still in flight discard its now-obsolete observation.
203200
generation += 1;
204-
cached = { timestamp: d.nowMs(), state: "starred" };
201+
cached = { timestamp: deps.nowMs(), state: "starred" };
205202
return { ok: true, status: { state: "starred", repo: STAR_REPO, url: STAR_REPO_URL } };
206203
}

tests/openai-chat-eof.test.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,18 @@ async function collect(gen: AsyncGenerator<AdapterEvent>): Promise<AdapterEvent[
1212
}
1313

1414
describe("openai-chat stream EOF fail-closed", () => {
15-
test("truncated stream (no [DONE], no finish_reason) yields a terminal error, not a clean done", async () => {
15+
test("truncated stream (no [DONE], no finish_reason) yields done when content was emitted", async () => {
1616
const response = new Response('data: {"choices":[{"delta":{"content":"par"}}]}\n\n');
1717
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
1818
const last = events[events.length - 1];
19-
expect(last.type).toBe("error");
19+
expect(last.type).toBe("done");
20+
expect(events.some(e => e.type === "error")).toBe(false);
21+
});
22+
23+
test("empty EOF without content still errors", async () => {
24+
const response = new Response("");
25+
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
26+
expect(events.at(-1)?.type).toBe("error");
2027
expect(events.some(e => e.type === "done")).toBe(false);
2128
});
2229

@@ -128,10 +135,49 @@ describe("openai-chat stream EOF fail-closed", () => {
128135
expect(events.some(e => e.type === "error")).toBe(false);
129136
});
130137

131-
test("genuinely truncated stream WITHOUT a trailing newline still fails closed", async () => {
132-
// Mid-content frame, no terminator, no newline — must remain a terminal error.
138+
test("genuinely truncated stream WITHOUT a trailing newline completes when content was emitted", async () => {
139+
// Mid-content frame, no terminator, no newline — content was yielded, so accept done.
133140
const response = new Response('data: {"choices":[{"delta":{"content":"par"}}]}');
134141
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
142+
expect(events.at(-1)?.type).toBe("done");
143+
expect(events.some(e => e.type === "error")).toBe(false);
144+
});
145+
146+
test("EOF with pending tool calls and no finish_reason fails closed", async () => {
147+
const response = new Response(
148+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\\"a\\":"}}]}}]}\n\n',
149+
);
150+
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
151+
expect(events.at(-1)?.type).toBe("error");
152+
expect(events.some(e => e.type === "done")).toBe(false);
153+
expect(events.some(e => e.type === "tool_call_end")).toBe(false);
154+
});
155+
156+
test("reasoning-only EOF without finish_reason fails closed", async () => {
157+
const response = new Response(
158+
'data: {"choices":[{"delta":{"reasoning_content":"thinking..."}}]}\n\n',
159+
);
160+
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
161+
expect(events.at(-1)?.type).toBe("error");
162+
expect(events.some(e => e.type === "done")).toBe(false);
163+
});
164+
165+
test("usage-only EOF with pending tool calls fails closed", async () => {
166+
const response = new Response(
167+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{}"}}]}}]}\n\n' +
168+
'data: {"choices":[],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}\n\n',
169+
);
170+
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
171+
expect(events.at(-1)?.type).toBe("error");
172+
expect(events.some(e => e.type === "done")).toBe(false);
173+
expect(events.some(e => e.type === "tool_call_end")).toBe(false);
174+
});
175+
176+
test("usage-only EOF without answer text fails closed", async () => {
177+
const response = new Response(
178+
'data: {"choices":[],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}\n\n',
179+
);
180+
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
135181
expect(events.at(-1)?.type).toBe("error");
136182
expect(events.some(e => e.type === "done")).toBe(false);
137183
});

tests/server-auth.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1955,14 +1955,18 @@ describe("server local API auth", () => {
19551955
});
19561956

19571957
test("missing or non-string detail never authorizes a pool retry", async () => {
1958-
for (const body of ["{}", '{"detail":null}', '{"detail":400}', '{"detail":{"message":"unsupported"}}']) {
1959-
const harness = await startPoolRetryHarness(() => rejectionResponse(body));
1960-
try {
1958+
const bodies = ["{}", '{"detail":null}', '{"detail":400}', '{"detail":{"message":"unsupported"}}'];
1959+
let nextBody = 0;
1960+
const harness = await startPoolRetryHarness(() => rejectionResponse(bodies[nextBody++]!));
1961+
try {
1962+
for (const body of bodies) {
1963+
const priorDispatches = harness.dispatches.length;
19611964
await expectOriginal400(await harness.request(), body);
1962-
expect(harness.dispatches).toEqual(["acct-pool-a"]);
1963-
} finally {
1964-
await stopPoolRetryHarness(harness);
1965+
expect(harness.dispatches.slice(priorDispatches)).toEqual(["acct-pool-a"]);
19651966
}
1967+
expect(nextBody).toBe(bodies.length);
1968+
} finally {
1969+
await stopPoolRetryHarness(harness);
19661970
}
19671971
});
19681972

0 commit comments

Comments
 (0)