Skip to content

Commit 2ffdb64

Browse files
fix(ui-e2e): studio-customize-journey reads open their own connection instead of a stale keep-alive socket (#30555)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiNjyvCdXYygFqntMNR7S2dW)_ Fixes #30524 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:`studio-customize-journey` 这条在线 e2e 的接口读取,每次都新开一条连接(`Connection: close`),不再复用空闲了几秒的旧连接;断言一条没动。 - **为什么改**:这条用例在合并队列里三次随机报 `read ECONNRESET`,把无关的绿 PR 踢出队列、拖累后面整队重跑。实测原因是测试客户端复用了一条快要被服务器按空闲超时(6 秒)关掉的连接,不是服务器出错。 - **风险与回滚**:只改一个测试文件,不发布任何包;每次读多一次本机建连,耗时可忽略。回滚即还原这一个文件。 - **先看**:`ui/e2e/live/studio-customize-journey.spec.ts` 顶部新增的 `request` fixture 及其注释(唯一改动处)。 - **两分钟验证**:打开本 PR 的 `Live E2E UI` 检查,应为绿色,日志里 `studio-customize-journey.spec.ts` 一行为 ✓。 - **需要你做的**:无,席位落地。 ## What changes `ui/e2e/live/studio-customize-journey.spec.ts` overrides the `request` fixture for this spec only: the context is `playwright.request.newContext({ extraHTTPHeaders: { Connection: 'close' } })`, so every API read the spec makes opens its own connection and none rides a pooled keep-alive socket. The config's `storageState` and other context options still reach it (the runner fills every option the call does not name). The browser's traffic is unchanged. Every assertion, step and cleanup is unchanged. ## Cause (measured) - Playwright 1.63's `APIRequestContext` pools sockets with `http.Agent({ keepAlive: true })` and no agent `timeout`. With no agent timeout, Node's agent does not apply the server's `Keep-Alive: timeout=5` hint, so a pooled socket is kept until the server closes it. - The backend (`@hono/node-server` on Node 22, default options) answers `keep-alive: timeout=5` and closes an idle socket at `keepAliveTimeout` 5000 + `keepAliveTimeoutBuffer` 1000 = 6000 ms (both read from `http.createServer()` on Node 22.23.4). - In this spec the read right after the customize step (`threeReads`, the `servedColumns` GET) reuses the socket of the read before the browser steps. A client-side socket trace measured its idle at **4710–5153 ms** on 5 solo runs and **4785–5010 ms** on 3 runs of the full CI allowlist. That is the largest idle of any API read in the allowlist, and it is about one second short of the server's close. A slower runner pushes it onto 6 s. - Against the real booted backend, the spec's own GET on a socket idled 5994–6000 ms: **7 of 40 failed** with `apiRequestContext.get: read ECONNRESET` (3) or `socket hang up` (4). With `Connection: close`: **0 of 40**. - The three CI hits (PR #30341 run 41981408406 job 127092282802, merge-group jobs 127279783258 and 127290410036) fail at the same call, `servedColumns` from `threeReads` at spec line 159, right after `publishPair`. The test dies at 6.5–6.6 s, 16 others pass, and the backend pid recorded at boot is the pid stopped at the end. The next spec passes within 4 s, while a backend boot takes 14 s. So nothing restarted the server: one pooled socket was reset. ## Tests The fixed spec ran 12/12 green against a stack booted the way the `Live E2E UI` job boots it (start-backend.sh, `vite preview` proxying `/api`, `--retries 0`). The derived gate commands ran beside it, so the box was loaded. A socket trace of the fixed spec shows **19 fresh connections and 0 reused**. The unfixed spec showed 16 reused per run, the largest idle 5153 ms. <details><summary>Spec run, fixed head e8662a4, --repeat-each 12 (list lines condensed)</summary> ``` Running 12 tests using 1 worker ✓ 1 … studio-customize-journey.spec.ts:143:1 … (17.8s) ✓ 2 … (15.6s) ✓ 3 … (15.0s) ✓ 4 … (15.9s) ✓ 5 … (20.4s) ✓ 6 … (17.8s) ✓ 7 … (16.9s) ✓ 8 … (15.2s) ✓ 9 … (15.6s) ✓ 10 … (14.5s) ✓ 11 … (14.4s) ✓ 12 … (14.0s) 12 passed (3.4m) ``` </details> <details><summary>Real backend, the spec's GET after an idle of N ms, 10 tries each</summary> ``` pooled (control) idle 5994ms -> {"200":7,"apiRequestContext.get: socket hang up":2,"apiRequestContext.get: read ECONNRESET":1} idle 5996ms -> {"200":6,"apiRequestContext.get: read ECONNRESET":2,"apiRequestContext.get: socket hang up":2} idle 5998ms -> {"200":10} idle 6000ms -> {"200":10} Connection: close idle 5994ms -> {"200":10} idle 5996ms -> {"200":10} idle 5998ms -> {"200":10} idle 6000ms -> {"200":10} ``` </details> <details><summary>Unfixed spec: reused-socket idle before each read (ms), full allowlist x3</summary> ``` 5010 GET /api/v1/meta/view/showcase_task.in_progress (journey, after customize) 4444 GET /api/v1/meta/object/showcase_task (journey, after the extension step) 4123 GET /api/v1/meta/page/e2e_studio_editor_… (studio-editor) 3537 GET /api/v1/meta/object/e2e_asset… (studio-object-designer) 4785 / 4420 / 4090 / 3538 · 4791 / 4219 / 4007 / 3498 (rounds 2 and 3, same order) 51 passed (2.9m) ``` </details> ## Acceptance notes - The unfixed spec did not go red locally: 20/20 alone and 51/51 in the allowlist x3. On this box the idle stays near 5 s, under the server's 6 s close. The failure was reproduced at the socket level against the real backend (above). - The CI artifacts' `backend.log` could not be read from the measuring container: the artifact blob host is refused by its egress proxy. The no-restart reading rests on the job logs: the same pid at boot and at stop, and the next spec passing. - Other live specs make reads after Studio steps on a reused socket, with a measured idle of 3.5–4.1 s (`studio-editor`, `studio-object-designer`). They are under the 6 s close today, so this PR leaves them unchanged. carrier: none. ## Verification - `node scripts/pm/dispatch-gates.mjs --commands` derived 19 commands for this diff. All 19 ran locally, each exit code captured before any pipe, and `--ran` reconciles them. The per-gate readings are on the issue's `os-dev-report` comment. - No changeset: the diff touches only `ui/e2e/live/**`, which no package's `files[]` ships (`ui/package.json` is the private `object-ui-monorepo`), so the PR carries `skip-changeset`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d54279 commit 2ffdb64

1 file changed

Lines changed: 35 additions & 1 deletion

File tree

‎ui/e2e/live/studio-customize-journey.spec.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect, type APIRequestContext, type Page, type Response } from '@playwright/test';
1+
import { test as base, expect, type APIRequestContext, type Page, type Response } from '@playwright/test';
22

33
/**
44
* Live e2e for the ADR-0146 customization journey, walked through the Studio
@@ -43,6 +43,40 @@ import { test, expect, type APIRequestContext, type Page, type Response } from '
4343
* two-minute budget for the specs step; the readings are on that card's PR.
4444
*/
4545

46+
/**
47+
* The spec's API reads each open their own connection (`Connection: close`).
48+
*
49+
* Playwright's `request` pools its sockets (`http.Agent({ keepAlive: true })`,
50+
* no idle timeout of its own), and the reads here are split by Studio steps
51+
* the browser drives: the read after the customize step reuses a socket that
52+
* has sat idle for about five seconds (4.7–5.2 s measured locally), the read
53+
* after the extension step for about four. A Node 22 server closes an idle
54+
* keep-alive socket at `keepAliveTimeout` + `keepAliveTimeoutBuffer` = 6 s, so
55+
* on a slower runner the read reaches a socket the server is closing and fails
56+
* with `read ECONNRESET` instead of an answer (objectstack#30524, reproduced
57+
* against @hono/node-server at a 5996–5999 ms idle). With a connection per
58+
* read no read depends on that race. The browser's own traffic is unchanged.
59+
*
60+
* The context is otherwise the one the default `request` fixture builds: the
61+
* project's request-relevant `use` options (the signed-in `storageState`,
62+
* `baseURL`, the device's `userAgent`, any `extraHTTPHeaders` and
63+
* `ignoreHTTPSErrors`) are passed on by name, so the reads run as the admin
64+
* session the spec was written for.
65+
*/
66+
const test = base.extend({
67+
request: async ({ playwright, storageState, baseURL, userAgent, extraHTTPHeaders, ignoreHTTPSErrors }, provide) => {
68+
const request = await playwright.request.newContext({
69+
storageState,
70+
baseURL,
71+
userAgent,
72+
ignoreHTTPSErrors,
73+
extraHTTPHeaders: { ...extraHTTPHeaders, Connection: 'close' },
74+
});
75+
await provide(request);
76+
await request.dispose();
77+
},
78+
});
79+
4680
const API = process.env.LIVE_API_URL || 'http://localhost:3000';
4781
const BASE = 'com.example.showcase';
4882
const PAIR = `${BASE}.custom`;

0 commit comments

Comments
 (0)