Skip to content

Commit 11f8190

Browse files
authored
fix(ci): stop each HTTP end-to-end app fully and start the next from an empty dev cache (#8686)
* fix(ci): start each HTTP end-to-end app from an empty Turbopack dev cache The stop-after suite failed 12 of 175 CI runs (Oct 1-6), always in its own `next dev` app, which restored the Turbopack dev cache (.next/dev) the SCIM/version-compare app left in the same job. That app ran with other NEXT_PUBLIC_* values, and `next dev` SIGKILLs its server 100ms after SIGTERM. Restoring the cache: - panicked Turbopack at startup (inner_of_upper_lost_follower), 6 runs; - panicked mid-compile of the execute route ("socket connection was closed unexpectedly"), 4 runs; - wedged that compile until the 300s request timeout ("The operation timed out."), 2 runs. No run reached the executor: the cleanup check found no open execution log. - Each app step removes .next/dev before starting, so no app restores another app's cache. - A failing step prints the server log tail, not only a startup failure. - The suite compiles the execute route with a refused request under its own 300s budget and named check; every later request and CLI run is bounded at 60s. A timed-out or dropped request names the route and elapsed time, lands in the report with a null status, and a CLI timeout is reported as one. * fix(ci): start the desktop inbox app from an empty Turbopack dev cache too * fix(e2e): record a status only for a fully read stop-after response * fix(ci): stop each HTTP end-to-end app's whole process session before the next one starts * fix(e2e): compile the desktop executor routes before timing executor calls
1 parent 482f146 commit 11f8190

4 files changed

Lines changed: 193 additions & 70 deletions

File tree

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/usr/bin/env bash
2+
# Stops every process in a session started with `setsid`, and returns only once none is left.
3+
#
4+
# Usage: stop-session.sh <session-leader-pid>
5+
#
6+
# `next dev` runs its server and workers as child processes. Waiting on `next dev` alone returns
7+
# while those can still be running, and the next app in the job reuses the same .next directory.
8+
# The leader stays a zombie until the shell that started it waits on it, so zombies don't count.
9+
set -u
10+
11+
leader=$1
12+
13+
running() {
14+
ps -s "$leader" -o pid=,stat= 2>/dev/null | awk '$2 !~ /^Z/ { found = 1 } END { exit !found }'
15+
}
16+
17+
leader_running() {
18+
ps -p "$leader" -o stat= 2>/dev/null | grep -qv '^Z'
19+
}
20+
21+
wait_for() {
22+
for _ in $(seq 1 100); do
23+
"$@" || return 0
24+
sleep 0.1
25+
done
26+
return 1
27+
}
28+
29+
kill -TERM -- "-$leader" 2>/dev/null || true
30+
wait_for leader_running
31+
if running; then
32+
echo "Processes from session $leader outlived its leader:"
33+
ps -s "$leader" -o pid,stat,etimes,args 2>/dev/null | cut -c1-200 || true
34+
fi
35+
wait_for running && exit 0
36+
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
41+
42+
echo "::error::Processes from session $leader survived SIGKILL."
43+
exit 1

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

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -232,17 +232,22 @@ jobs:
232232
report_dir="$RUNNER_TEMP/e2e"
233233
server_log="$report_dir/scim-next.log"
234234
mkdir -p "$report_dir"
235-
node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3017 > "$server_log" 2>&1 &
235+
rm -rf .next/dev
236+
setsid node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3017 > "$server_log" 2>&1 &
236237
server_pid=$!
237238
finish() {
238-
kill "$server_pid" 2>/dev/null || true
239+
status=$?
240+
bash ../../.github/scripts/stop-session.sh "$server_pid" || status=1
239241
wait "$server_pid" 2>/dev/null || true
240242
awk '/^ (GET|POST|PUT|PATCH|DELETE|HEAD) \/api\// { print }' "$server_log" > "$report_dir/scim-http-status.log"
243+
if [ "$status" -ne 0 ]; then
244+
tail -n 200 "$server_log"
245+
exit "$status"
246+
fi
241247
}
242248
trap finish EXIT
243249
fail_startup() {
244250
echo "::error::$1"
245-
tail -n 200 "$server_log"
246251
exit 1
247252
}
248253
started=$SECONDS
@@ -267,6 +272,12 @@ jobs:
267272
# A self-hosted app: hosted billing admits a run only through a Redis usage
268273
# reservation, and the SCIM suite above asserts PostgreSQL rate-limit storage,
269274
# so workflow execution gets its own app rather than adding Redis to that one.
275+
#
276+
# The apps in this job share apps/sim/.next. Each starts from an empty Turbopack
277+
# dev cache: one written under other NEXT_PUBLIC_* values, by a server `next dev`
278+
# SIGKILLs 100ms after SIGTERM, can panic Turbopack or wedge a route compile on
279+
# restore. Each runs in its own session, and stop-session.sh returns only once
280+
# all of it has exited, so no app starts beside one still writing .next/dev.
270281
- name: Verify single-block workflow runs over real HTTP
271282
working-directory: apps/sim
272283
env:
@@ -283,17 +294,22 @@ jobs:
283294
report_dir="$RUNNER_TEMP/e2e"
284295
server_log="$report_dir/stop-after-next.log"
285296
mkdir -p "$report_dir"
286-
node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3018 > "$server_log" 2>&1 &
297+
rm -rf .next/dev
298+
setsid node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3018 > "$server_log" 2>&1 &
287299
server_pid=$!
288300
finish() {
289-
kill "$server_pid" 2>/dev/null || true
301+
status=$?
302+
bash ../../.github/scripts/stop-session.sh "$server_pid" || status=1
290303
wait "$server_pid" 2>/dev/null || true
291304
awk '/^ (GET|POST|PUT|PATCH|DELETE|HEAD) \/api\// { print }' "$server_log" > "$report_dir/stop-after-http-status.log"
305+
if [ "$status" -ne 0 ]; then
306+
tail -n 200 "$server_log"
307+
exit "$status"
308+
fi
292309
}
293310
trap finish EXIT
294311
fail_startup() {
295312
echo "::error::$1"
296-
tail -n 200 "$server_log"
297313
exit 1
298314
}
299315
started=$SECONDS
@@ -329,17 +345,22 @@ jobs:
329345
report_dir="$RUNNER_TEMP/e2e"
330346
server_log="$report_dir/desktop-inbox-next.log"
331347
mkdir -p "$report_dir"
332-
node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3019 > "$server_log" 2>&1 &
348+
rm -rf .next/dev
349+
setsid node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3019 > "$server_log" 2>&1 &
333350
server_pid=$!
334351
finish() {
335-
kill "$server_pid" 2>/dev/null || true
352+
status=$?
353+
bash ../../.github/scripts/stop-session.sh "$server_pid" || status=1
336354
wait "$server_pid" 2>/dev/null || true
337355
awk '/^ (GET|POST|PUT|PATCH|DELETE|HEAD) \/api\// { print }' "$server_log" > "$report_dir/desktop-inbox-http-status.log"
356+
if [ "$status" -ne 0 ]; then
357+
tail -n 200 "$server_log"
358+
exit "$status"
359+
fi
338360
}
339361
trap finish EXIT
340362
fail_startup() {
341363
echo "::error::$1"
342-
tail -n 200 "$server_log"
343364
exit 1
344365
}
345366
started=$SECONDS

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,17 @@ async function run() {
413413
assert(registration.reconcileMs < PICKUP_GRACE_SECONDS * 1000)
414414
})
415415

416+
/** `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 })
424+
}
425+
})
426+
416427
/** Starts absent, so only the stream open below can mark the device present. */
417428
await redis.del(`desktop:presence:${desktop.deviceId}`)
418429
const doorbell = openDoorbell(desktop)

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

Lines changed: 109 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits'
3737
const logger = createLogger('WorkflowStopAfterE2E')
3838
const execFileAsync = promisify(execFile)
3939
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024
40-
/** The first execute request cold-compiles the route under `next dev`. */
41-
const REQUEST_TIMEOUT_MS = 300_000
40+
/** The first execute request cold-compiles the route's module graph under `next dev`. */
41+
const ROUTE_COMPILE_TIMEOUT_MS = 300_000
42+
/** Every later request hits the compiled route; the slowest fixture run waits about `SLOW_MS`. */
43+
const REQUEST_TIMEOUT_MS = 60_000
4244
const SLOW_SECONDS = 4
4345
const SLOW_MS = SLOW_SECONDS * 1000
4446
const startedAt = new Date().toISOString()
@@ -67,7 +69,8 @@ const personalKey = `sk-sim-fixture-${generateId()}`
6769
const cliPath = fileURLToPath(new URL('../../../packages/sim-cli/src/index.ts', import.meta.url))
6870
const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] =
6971
[]
70-
const requests: { method: string; path: string; status: number; durationMs: number }[] = []
72+
/** `status` is null when the request ended without a complete response. */
73+
const requests: { method: string; path: string; status: number | null; durationMs: number }[] = []
7174
let directory: string | undefined
7275

7376
interface PipelineFixture {
@@ -225,37 +228,48 @@ async function seed() {
225228
})
226229
}
227230

231+
function isTimeout(error: unknown): boolean {
232+
return error instanceof DOMException && error.name === 'TimeoutError'
233+
}
234+
228235
async function execute(
229236
workflowId: string,
230237
body: V2ExecuteWorkflowBody,
231-
expectedStatus = 200
238+
{ expectedStatus = 200, timeoutMs = REQUEST_TIMEOUT_MS } = {}
232239
): Promise<Record<string, unknown>> {
233240
const url = new URL(`/api/v2/workflows/${workflowId}/execute`, baseUrl)
234241
const started = performance.now()
235-
// boundary-raw-fetch: protocol E2E exercises a separately running local app over real HTTP
236-
const response = await fetch(url, {
237-
method: 'POST',
238-
redirect: 'error',
239-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
240-
headers: {
241-
accept: 'application/json',
242-
'content-type': 'application/json',
243-
'x-api-key': personalKey,
244-
'x-forwarded-for': '127.0.0.1',
245-
},
246-
body: JSON.stringify(body),
247-
})
248-
requests.push({
249-
method: 'POST',
250-
path: url.pathname,
251-
status: response.status,
252-
durationMs: Math.round(performance.now() - started),
253-
})
254-
const text = await readResponseTextWithLimit(response, {
255-
maxBytes: MAX_RESPONSE_BYTES,
256-
label: 'Stop-after E2E response',
257-
})
258-
assert.equal(response.status, expectedStatus, `${url.pathname}: ${truncate(text, 500)}`)
242+
const elapsed = () => Math.round(performance.now() - started)
243+
let status: number | null = null
244+
let text: string
245+
try {
246+
// boundary-raw-fetch: protocol E2E exercises a separately running local app over real HTTP
247+
const response = await fetch(url, {
248+
method: 'POST',
249+
redirect: 'error',
250+
signal: AbortSignal.timeout(timeoutMs),
251+
headers: {
252+
accept: 'application/json',
253+
'content-type': 'application/json',
254+
'x-api-key': personalKey,
255+
'x-forwarded-for': '127.0.0.1',
256+
},
257+
body: JSON.stringify(body),
258+
})
259+
text = await readResponseTextWithLimit(response, {
260+
maxBytes: MAX_RESPONSE_BYTES,
261+
label: 'Stop-after E2E response',
262+
})
263+
status = response.status
264+
} catch (error) {
265+
const reason = isTimeout(error)
266+
? `no complete response within ${timeoutMs / 1000}s`
267+
: getErrorMessage(error)
268+
throw new Error(`POST ${url.pathname} failed after ${elapsed()} ms: ${reason}`)
269+
} finally {
270+
requests.push({ method: 'POST', path: url.pathname, status, durationMs: elapsed() })
271+
}
272+
assert.equal(status, expectedStatus, `${url.pathname}: ${truncate(text, 500)}`)
259273
return record(JSON.parse(text))
260274
}
261275

@@ -268,56 +282,90 @@ async function run(
268282
return data
269283
}
270284

271-
async function expectBadRequest(workflowId: string, body: V2ExecuteWorkflowBody, code: string) {
272-
const status = code === 'NOT_FOUND' ? 404 : 400
273-
const error = record((await execute(workflowId, body, status)).error)
285+
async function expectBadRequest(
286+
workflowId: string,
287+
body: V2ExecuteWorkflowBody,
288+
code: string,
289+
timeoutMs = REQUEST_TIMEOUT_MS
290+
) {
291+
const expectedStatus = code === 'NOT_FOUND' ? 404 : 400
292+
const error = record((await execute(workflowId, body, { expectedStatus, timeoutMs })).error)
274293
assert.equal(error.code, code)
275294
}
276295

277-
async function runCli(args: string[]): Promise<V2ExecuteWorkflowData> {
296+
/** Runs the CLI; a run it must fail exits non-zero and still prints the run on stdout. */
297+
async function execCli(
298+
args: string[]
299+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
278300
assert(directory, 'CLI fixture directory must exist')
279-
const { stdout } = await execFileAsync(
280-
'bun',
281-
[
282-
'--no-env-file',
283-
cliPath,
284-
'--endpoint',
285-
baseUrl.origin,
286-
'--workspace',
287-
workspaceId,
288-
'--output',
289-
'json',
290-
'workflows',
291-
'run',
292-
...args,
293-
],
294-
{
295-
cwd: directory,
296-
env: { ...process.env, SIM_CONFIG_DIR: directory, SIM_API_KEY: personalKey, NO_COLOR: '1' },
297-
timeout: REQUEST_TIMEOUT_MS,
298-
maxBuffer: MAX_RESPONSE_BYTES,
299-
}
301+
try {
302+
const { stdout, stderr } = await execFileAsync(
303+
'bun',
304+
[
305+
'--no-env-file',
306+
cliPath,
307+
'--endpoint',
308+
baseUrl.origin,
309+
'--workspace',
310+
workspaceId,
311+
'--output',
312+
'json',
313+
'workflows',
314+
'run',
315+
...args,
316+
],
317+
{
318+
cwd: directory,
319+
env: { ...process.env, SIM_CONFIG_DIR: directory, SIM_API_KEY: personalKey, NO_COLOR: '1' },
320+
timeout: REQUEST_TIMEOUT_MS,
321+
maxBuffer: MAX_RESPONSE_BYTES,
322+
}
323+
)
324+
return { exitCode: 0, stdout, stderr }
325+
} catch (error) {
326+
assert(isRecordLike(error), getErrorMessage(error))
327+
assert(!error.killed, `sim workflows run did not exit within ${REQUEST_TIMEOUT_MS / 1000}s`)
328+
assert(
329+
typeof error.code === 'number' &&
330+
typeof error.stdout === 'string' &&
331+
typeof error.stderr === 'string',
332+
getErrorMessage(error)
333+
)
334+
return { exitCode: error.code, stdout: error.stdout, stderr: error.stderr }
335+
}
336+
}
337+
338+
async function runCli(args: string[]): Promise<V2ExecuteWorkflowData> {
339+
const { exitCode, stdout, stderr } = await execCli(args)
340+
assert.equal(
341+
exitCode,
342+
0,
343+
`sim workflows run exited ${exitCode}: ${truncate(stderr || stdout, 500)}`
300344
)
301345
return v2ExecuteWorkflowDataSchema.parse(JSON.parse(stdout))
302346
}
303347

304348
/** A CLI run the command itself must fail: exits non-zero and prints the failed run. */
305349
async function runCliExpectingFailure(args: string[]): Promise<V2ExecuteWorkflowData> {
306-
try {
307-
await runCli(args)
308-
} catch (error) {
309-
assert(isRecordLike(error) && typeof error.stdout === 'string', getErrorMessage(error))
310-
assert.notEqual(error.code, 0, 'a failed run must exit non-zero')
311-
return v2ExecuteWorkflowDataSchema.parse(JSON.parse(error.stdout))
312-
}
313-
assert.fail('the CLI exited 0 for a run that must fail')
350+
const { exitCode, stdout } = await execCli(args)
351+
assert.notEqual(exitCode, 0, 'the CLI exited 0 for a run that must fail')
352+
return v2ExecuteWorkflowDataSchema.parse(JSON.parse(stdout))
314353
}
315354

316355
const selectAll = ['Slow.status', 'Check.status', 'After.status']
317356

318357
try {
319358
await check('seed disposable workspace, personal key and fixtures', seed)
320359

360+
await check('the execute route compiles and refuses a run before it starts', () =>
361+
expectBadRequest(
362+
pipeline.workflowId,
363+
{ run: { source: 'manual', stopAfterBlockId: '' } },
364+
'BAD_REQUEST',
365+
ROUTE_COMPILE_TIMEOUT_MS
366+
)
367+
)
368+
321369
let sourceRunId = ''
322370
await check('a full manual run executes every block and persists its state', async () => {
323371
const full = await run(pipeline.workflowId, {

0 commit comments

Comments
 (0)