Skip to content

Commit f98df1b

Browse files
authored
fix(e2e): compile every timed route first in the HTTP suites and stop sessions completely (#8701)
* fix(e2e): compile every timed route first in the HTTP suites and stop sessions completely - version compare: a first check compiles the deployment, version, compare and MCP routes under a 300s budget; later requests stay at 60s and a timeout names the route - desktop inbox: the warm-up also reads the inbox, so the 10s executor window covers no compile; stopping the executor waits for its in-flight pull, so teardown never runs under a claim and a timeout is reported as itself instead of a later 401 - stop-session.sh signals every process in the session, including one in its own process group, escalates to SIGKILL 10s after SIGTERM, and reports processes that outlived the leader only once the leader has exited - stop-after: a CLI that overflows its output buffer is reported as an overflow, not as a timeout * fix(ci): run the embedded CLI app with the same isolated lifecycle as the other HTTP end-to-end apps
1 parent 632ba60 commit f98df1b

5 files changed

Lines changed: 127 additions & 57 deletions

File tree

‎.github/scripts/stop-session.sh‎

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,39 +5,58 @@
55
#
66
# `next dev` runs its server and workers as child processes. Waiting on `next dev` alone returns
77
# while those can still be running, and the next app in the job reuses the same .next directory.
8+
# Every member of the session is signalled, including one that moved to its own process group.
89
# The leader stays a zombie until the shell that started it waits on it, so zombies don't count.
910
set -u
1011

1112
leader=$1
13+
grace_seconds=10
14+
15+
session_pids() {
16+
ps -s "$leader" -o pid=,stat= 2>/dev/null | awk '$2 !~ /^Z/ { print $1 }'
17+
}
18+
19+
signal_session() {
20+
local pids
21+
pids=$(session_pids)
22+
[ -z "$pids" ] || kill "-$1" $pids 2>/dev/null || true
23+
}
1224

1325
running() {
14-
ps -s "$leader" -o pid=,stat= 2>/dev/null | awk '$2 !~ /^Z/ { found = 1 } END { exit !found }'
26+
[ -n "$(session_pids)" ]
1527
}
1628

1729
leader_running() {
1830
ps -p "$leader" -o stat= 2>/dev/null | grep -qv '^Z'
1931
}
2032

21-
wait_for() {
22-
for _ in $(seq 1 100); do
33+
now_us() {
34+
echo "${EPOCHREALTIME/./}"
35+
}
36+
37+
# Waits while the given check holds, until the shared deadline. Succeeds once it stops holding.
38+
wait_while() {
39+
while (($(now_us) < deadline)); do
2340
"$@" || return 0
2441
sleep 0.1
2542
done
26-
return 1
43+
! "$@"
2744
}
2845

29-
kill -TERM -- "-$leader" 2>/dev/null || true
30-
wait_for leader_running
31-
if running; then
46+
deadline=$(($(now_us) + grace_seconds * 1000000))
47+
signal_session TERM
48+
wait_while leader_running
49+
if ! leader_running && running; then
3250
echo "Processes from session $leader outlived its leader:"
3351
ps -s "$leader" -o pid,stat,etimes,args 2>/dev/null | cut -c1-200 || true
3452
fi
35-
wait_for running && exit 0
53+
wait_while running && exit 0
3654

37-
echo "::warning::Processes from session $leader were still running 10s after SIGTERM:"
38-
ps -s "$leader" -o pid,stat,etimes,args 2>/dev/null || true
39-
kill -KILL -- "-$leader" 2>/dev/null || true
40-
wait_for running && exit 0
55+
echo "::warning::Processes from session $leader were still running ${grace_seconds}s after SIGTERM:"
56+
ps -s "$leader" -o pid,stat,etimes,args 2>/dev/null | cut -c1-200 || true
57+
deadline=$(($(now_us) + grace_seconds * 1000000))
58+
signal_session KILL
59+
wait_while running && exit 0
4160

42-
echo "::error::Processes from session $leader survived SIGKILL."
61+
echo "::error::Processes from session $leader survived SIGKILL for ${grace_seconds}s."
4362
exit 1

‎.github/workflows/test-build.yml‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,13 @@ jobs:
206206
# immediately, and either way the server log tail lands in the job log. No
207207
# step timeout: the job's bound covers a hang without cutting a slow but
208208
# healthy suite short of writing its report.
209+
#
210+
# Every `next dev` app in this job shares apps/sim/.next, so each one follows the
211+
# same lifecycle. It starts from an empty Turbopack dev cache: a cache written under
212+
# other NEXT_PUBLIC_* values, by a server that `next dev` SIGKILLs 100ms after
213+
# SIGTERM, can panic Turbopack or wedge a route compile on restore. It runs in its
214+
# own session, and stop-session.sh returns only once all of it has exited, so no app
215+
# starts beside one still writing .next/dev.
209216
- name: Verify SCIM, administration and workflow comparisons over real HTTP
210217
working-directory: apps/sim
211218
env:
@@ -285,16 +292,21 @@ jobs:
285292
report_dir="$RUNNER_TEMP/e2e"
286293
server_log="$report_dir/cli-next.log"
287294
mkdir -p "$report_dir"
288-
node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3018 > "$server_log" 2>&1 &
295+
rm -rf .next/dev
296+
setsid node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3018 > "$server_log" 2>&1 &
289297
server_pid=$!
290298
finish() {
291-
kill "$server_pid" 2>/dev/null || true
299+
status=$?
300+
bash ../../.github/scripts/stop-session.sh "$server_pid" || status=1
292301
wait "$server_pid" 2>/dev/null || true
302+
if [ "$status" -ne 0 ]; then
303+
tail -n 200 "$server_log"
304+
exit "$status"
305+
fi
293306
}
294307
trap finish EXIT
295308
fail_startup() {
296309
echo "::error::$1"
297-
tail -n 200 "$server_log"
298310
exit 1
299311
}
300312
started=$SECONDS
@@ -315,12 +327,6 @@ jobs:
315327
# A self-hosted app: hosted billing admits a run only through a Redis usage
316328
# reservation, and the SCIM suite above asserts PostgreSQL rate-limit storage,
317329
# so workflow execution gets its own app rather than adding Redis to that one.
318-
#
319-
# The apps in this job share apps/sim/.next. Each starts from an empty Turbopack
320-
# dev cache: one written under other NEXT_PUBLIC_* values, by a server `next dev`
321-
# SIGKILLs 100ms after SIGTERM, can panic Turbopack or wedge a route compile on
322-
# restore. Each runs in its own session, and stop-session.sh returns only once
323-
# all of it has exited, so no app starts beside one still writing .next/dev.
324330
- name: Verify single-block workflow runs over real HTTP
325331
working-directory: apps/sim
326332
env:

‎apps/sim/scripts/test-desktop-inbox-e2e.ts‎

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -284,20 +284,16 @@ function openDoorbell(desktop: Desktop) {
284284
function startExecutor(desktop: Desktop, doorbell: ReturnType<typeof openDoorbell>) {
285285
const ran = new Map<string, { toolName: string; chatId: string; executionToken: string }>()
286286
const completed = new Set<string>()
287-
let running = false
287+
let inFlight: Promise<void> | null = null
288288
let rerun = false
289289
let stopped = false
290-
const pull = async () => {
291-
if (running) {
292-
rerun = true
293-
return
294-
}
295-
running = true
290+
const drain = async () => {
296291
try {
297292
do {
298293
rerun = false
299294
const inbox = await pullInbox(desktop)
300295
for (const item of inbox.items) {
296+
if (stopped) return
301297
if (item.kind !== 'call' || ran.has(item.toolCallId)) continue
302298
const claimBody: ClaimDesktopToolBody = {
303299
deviceId: desktop.deviceId,
@@ -351,8 +347,16 @@ function startExecutor(desktop: Desktop, doorbell: ReturnType<typeof openDoorbel
351347
}
352348
} while (rerun && !stopped)
353349
} finally {
354-
running = false
350+
inFlight = null
351+
}
352+
}
353+
const pull = () => {
354+
if (inFlight) {
355+
rerun = true
356+
return inFlight
355357
}
358+
inFlight = drain()
359+
return inFlight
356360
}
357361
const offDoorbell = doorbell.onEvent(
358362
() => void pull().catch((error) => logger.error('pull failed', error))
@@ -361,14 +365,19 @@ function startExecutor(desktop: Desktop, doorbell: ReturnType<typeof openDoorbel
361365
() => void pull().catch((error) => logger.error('reconcile failed', error)),
362366
RECONCILE_MS
363367
)
364-
void pull()
368+
void pull().catch((error) => logger.error('pull failed', error))
365369
return {
366370
ran,
367371
completed,
368-
stop() {
372+
/**
373+
* Resolves once the pull in flight has settled, so the fixture is never torn down under a
374+
* request the executor already started.
375+
*/
376+
async stop() {
369377
stopped = true
370378
offDoorbell()
371379
clearInterval(timer)
380+
await inFlight?.catch(() => {})
372381
},
373382
}
374383
}
@@ -414,15 +423,19 @@ async function run() {
414423
})
415424

416425
/** `next dev` compiles a route on its first request, which must not count against the timed checks. */
417-
await check('refuses malformed claim, lease and completion bodies', async () => {
418-
for (const path of [
419-
'/api/desktop/tool/claim',
420-
'/api/desktop/tool/lease',
421-
'/api/desktop/tool/complete',
422-
]) {
423-
await request(desktop, 'POST', path, { body: {}, expected: 400 })
426+
await check(
427+
'reads the inbox and refuses malformed claim, lease and completion bodies',
428+
async () => {
429+
await pullInbox(desktop)
430+
for (const path of [
431+
'/api/desktop/tool/claim',
432+
'/api/desktop/tool/lease',
433+
'/api/desktop/tool/complete',
434+
]) {
435+
await request(desktop, 'POST', path, { body: {}, expected: 400 })
436+
}
424437
}
425-
})
438+
)
426439

427440
/** Starts absent, so only the stream open below can mark the device present. */
428441
await redis.del(`desktop:presence:${desktop.deviceId}`)
@@ -544,7 +557,7 @@ async function run() {
544557
})
545558
})
546559
} finally {
547-
executor.stop()
560+
await executor.stop()
548561
}
549562

550563
await check('tells the device to cancel a call whose chat was stopped', async () => {

‎apps/sim/scripts/test-workflow-stop-after-e2e.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,10 @@ async function execCli(
324324
return { exitCode: 0, stdout, stderr }
325325
} catch (error) {
326326
assert(isRecordLike(error), getErrorMessage(error))
327+
assert(
328+
error.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
329+
`sim workflows run printed more than ${MAX_RESPONSE_BYTES} bytes`
330+
)
327331
assert(!error.killed, `sim workflows run did not exit within ${REQUEST_TIMEOUT_MS / 1000}s`)
328332
assert(
329333
typeof error.code === 'number' &&

‎apps/sim/scripts/test-workflow-version-compare-e2e.ts‎

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
3636
const logger = createLogger('WorkflowVersionCompareE2E')
3737
const execFileAsync = promisify(execFile)
3838
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024
39+
/** The first request to each route cold-compiles it under `next dev`. */
40+
const ROUTE_COMPILE_TIMEOUT_MS = 300_000
41+
/** Every later request hits a compiled route. */
3942
const REQUEST_TIMEOUT_MS = 60_000
4043
const startedAt = new Date().toISOString()
4144

@@ -309,30 +312,39 @@ async function seedBrowserWorkflows() {
309312
}
310313
}
311314

312-
async function observedFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
315+
async function observedFetch(
316+
input: string | URL | Request,
317+
init?: RequestInit,
318+
timeoutMs = REQUEST_TIMEOUT_MS
319+
): Promise<Response> {
313320
const url = new URL(input instanceof Request ? input.url : input)
314321
assert.equal(
315322
url.origin,
316323
baseUrl.origin,
317324
'E2E requests must remain on the configured loopback app'
318325
)
326+
const method = init?.method ?? (input instanceof Request ? input.method : 'GET')
319327
const started = performance.now()
320328
const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined)
321-
// boundary-raw-fetch: protocol E2E exercises a separately running local app over real HTTP
322-
const response = await fetch(input, {
323-
...init,
324-
redirect: 'error',
325-
signal: signal
326-
? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
327-
: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
328-
})
329-
requests.push({
330-
method: init?.method ?? (input instanceof Request ? input.method : 'GET'),
331-
path: url.pathname,
332-
status: response.status,
333-
durationMs: Math.round(performance.now() - started),
334-
})
335-
return response
329+
const timeout = AbortSignal.timeout(timeoutMs)
330+
try {
331+
// boundary-raw-fetch: protocol E2E exercises a separately running local app over real HTTP
332+
const response = await fetch(input, {
333+
...init,
334+
redirect: 'error',
335+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
336+
})
337+
requests.push({
338+
method,
339+
path: url.pathname,
340+
status: response.status,
341+
durationMs: Math.round(performance.now() - started),
342+
})
343+
return response
344+
} catch (error) {
345+
if (!timeout.aborted) throw error
346+
throw new Error(`${method} ${url.pathname}: no response within ${timeoutMs / 1000}s`)
347+
}
336348
}
337349

338350
async function get(path: string, auth: { key?: string; session?: string } = {}, expected = 200) {
@@ -432,6 +444,22 @@ async function runMcp() {
432444
try {
433445
await check('seed disposable workspace, versions and credentials', seed)
434446
if (browserFixturesPath) await check('seed browser scenarios', seedBrowserWorkflows)
447+
await check('every route under test compiles and refuses an anonymous request', async () => {
448+
for (const [method, path] of [
449+
['GET', `/api/workflows/${workflowId}/deployments/1`],
450+
['GET', `/api/v2/workflows/${workflowId}/versions/1`],
451+
['GET', comparisonPath(1, 2)],
452+
['POST', '/api/mcp'],
453+
] as const) {
454+
const response = await observedFetch(
455+
new URL(path, baseUrl),
456+
{ method, headers: { 'x-forwarded-for': '127.0.0.1' } },
457+
ROUTE_COMPILE_TIMEOUT_MS
458+
)
459+
await response.body?.cancel()
460+
assert.equal(response.status, 401, `${method} ${path}: unexpected HTTP status`)
461+
}
462+
})
435463
await check(
436464
'session preview migrates both versions while the public archive stays pinned',
437465
async () => {

0 commit comments

Comments
 (0)