test(studio): the edit accuracy bench uses the port its server bound - #4953
Conversation
A busy port made the CLI bind the next free one, and the bench then failed the case with "studio moved from port N to N+1" instead of using it.
jrusso1020
left a comment
There was a problem hiding this comment.
Reviewed at 73829351: case.mjs (startServer/stopServer and the helpers around them), run.mjs's runOne, the new server.test.mjs, and the CLI's findPortAndServe in portUtils.ts, which the description relies on. Approving.
What I checked:
- The announced port is the bound one.
findPortAndServebinds withserver.listen(port, host)and moves to the next port onEADDRINUSE, and preview builds its URL fromresult.port. The bench always passes--force-new, so thealready-runningreuse branch can't return another project's server. The scan loop prints nothing, so the firsthttp://localhost:Nin the log is the real announcement. - Readiness is checked on that port.
up(announced)fetches/api/projectson127.0.0.1, the same loopback host preview binds by default.Number(undefined)isNaN, so the loop keeps waiting until the URL appears. - Every caller is updated.
run.mjsis the only production caller. It stopsserved.childon both paths and opens Studio atserved.port.liveServersstill tracks the child, sokillServerscovers a server that moved. - Dropping the "already serving" pre-check is safe. A leftover server on the asked port now only pushes the new one to the next port. It can't be measured by mistake, because the bench talks only to the port that was announced.
Tests I ran:
server.test.mjspasses 3 of 3 at this head.- With
case.mjsrestored to main, it fails withstudio moved from port 1 to 34505, so the test discriminates.
Nit (not blocking): announcedPort takes the first localhost URL in the whole log. If preview ever prints another localhost URL before its own, for example a hint about a busy port, the bench would wait on the wrong port and time out after 60 s. It wouldn't mis-measure, because up() has to answer. Matching the last URL, or the exact announcement line, would remove that dependence.
Verdict: APPROVE
Reasoning: The bench now follows the port the CLI actually bound, which the CLI only announces after a successful listen. The new test fails on the old harness and passes here, and nothing outside the bench changes.
— Rames Jusso
Edit accuracy: accurate 1216 (base branch 1216), smooth 1092 of thoseThe gate passes. Quarantined, measured but not gated (1)
|
Windows has no process groups, so the group kill never reached the server and the stop waited forever (the new server test timed out on windows-latest). taskkill /T, the CLI helper for the same job, ends the tree there.
jrusso1020
left a comment
There was a problem hiding this comment.
Re-reviewed at b454628c. I approved 73829351, and that commit is an ancestor of this head. Since then, the two commits change only case.mjs (+8). Approving.
What I checked:
- The Windows stop now reaches the server. On
win32,signalGroupcalls the CLI's ownterminateWindowsProcessTree, which runstaskkill /PID <pid> /T /F, instead ofprocess.kill(-pid).- Its rejection message ends in
status <code>, so/status 128$/matches only the "process already gone" exit. - Any other failure is re-thrown into a voided promise. It surfaces as an unhandled rejection, as the commit says it should.
- The relative import resolves to
packages/cli/src/utils/processTree.ts. The bench runs under bun and the tests under vitest, and both load.ts.
- Its rejection message ends in
windowsHide: truegoes withdetached: true, which on Windows would otherwise open a console window for the server.- Linux and macOS behave as before. The
process.kill(-pid)path is unchanged. - Signal-safe cleanup still runs.
killServers()fromrun.mjs's exit and signal handlers startstaskkillsynchronously, becausespawncreates the process beforeprocess.exit. It doesn't depend on the promise settling.
Tests I ran:
- At this head, all of
tests/e2e/edit-accuracy/passes: 9 files, 58 of 58 (vitest). - The Windows branch, simulated. I set
process.platformtowin32and put a stand-intaskkillon PATH that SIGKILLs/PIDand exits 128 when the PID is gone.startServer→stopServerreturned in 234 ms with the child dead, after onetaskkill /PID <pid> /T /F.- A second
taskkillagainst a PID that was already gone exited 128 and was swallowed.
Nit (not blocking, and on main before this PR): stopServer returns early only on child.exitCode !== null. A server that dies from a signal has exitCode === null and signalCode set, and once("exit") never fires again. So stopServer on it signals twice and then waits on exited forever.
- I reproduced this here. After a SIGKILL to the server,
stopServerwas still waiting after 8 s. - The bench hits this if the server crashes from a signal mid-case, for example under the OOM killer.
- Checking
child.exitCode !== null || child.signalCode !== nullwould close it.
Verdict: APPROVE
Reasoning: The delta only gives the bench a Windows way to stop its server, reusing the CLI's existing taskkill helper, and leaves POSIX untouched. A simulated Windows stop ends the server on the first taskkill, and the edit-accuracy suite passes.
— Rames Jusso
…en-com#4956) * ci: a test-only change under studio or player needs no captures Tests are not behaviour a user sees, like the markdown the check already skips. A bench-harness PR (heygen-com#4953) was asked for Before and After screenshots. * ci: pin a mixed source and test change, and say tests are not counted
What
The edit accuracy bench failed a case whenever its Studio port was busy.
startServer(edit-accuracycase.mjs) asks the CLI for--port N; when another process holds N, the CLI binds the next free port and announces it, and the bench then threwstudio moved from port N to N+1, so the case scored as an error without running. A port that something else was serving on failed the same way earlier (port N is already serving).startServernow returns{ child, port }with the port the CLI announced and answers on, andrunOne(run.mjs) opens Studio at that port. The asked-for port is only a starting point.Why
On a shared bench machine, three rotate cases errored this way in one full run while another process held one job's port; each passed 3 of 3 when rerun alone. A case's verdict must not depend on what else is listening.
Related work
Refs the manual-edit bench work (#4760 added the bench).
How
The CLI binds with a retry on
EADDRINUSEand only then printshttp://localhost:<port>, so the announced port is the one this server holds. The bench waits for that announcement and for/api/projectsto answer on that port, as before. The "already serving" pre-check and the "moved" throw go, since both treated a busy port as a failure.Test plan
server.test.mjs(new): a stand-in CLI binds a port the OS picks, not the one asked for, and announces it. With the oldstartServerit fails withstudio moved from port 1 to <n>; with this change it passes 3 of 3, and it removes its temp dir.move-none-px-r0-root-z100,--jobs 1 --port 47612) while another HTTP server held 47612: before,ERROR studio moved from port 47612 to 47613in 1.1 s; after, accurate in 3 of 3 runs (one run flagged for smoothness only, on a loaded machine).Before
Main's harness, one real case, while another HTTP server holds the asked-for port: the case errors without running.
After
This PR's harness, same held port, three runs: Studio starts on the next port and the case is measured and accurate each time.
Size
Two functions in the bench harness and one test. It ships alone: it fixes a flake in the harness every bench run uses, and no open PR touches these lines.