Skip to content

Commit 02c78d6

Browse files
committed
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.
1 parent 94cba3b commit 02c78d6

2 files changed

Lines changed: 121 additions & 63 deletions

File tree

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,17 +232,19 @@ jobs:
232232
report_dir="$RUNNER_TEMP/e2e"
233233
server_log="$report_dir/scim-next.log"
234234
mkdir -p "$report_dir"
235+
rm -rf .next/dev
235236
node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3017 > "$server_log" 2>&1 &
236237
server_pid=$!
237238
finish() {
239+
status=$?
238240
kill "$server_pid" 2>/dev/null || true
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+
[ "$status" -eq 0 ] || tail -n 200 "$server_log"
241244
}
242245
trap finish EXIT
243246
fail_startup() {
244247
echo "::error::$1"
245-
tail -n 200 "$server_log"
246248
exit 1
247249
}
248250
started=$SECONDS
@@ -267,6 +269,12 @@ jobs:
267269
# A self-hosted app: hosted billing admits a run only through a Redis usage
268270
# reservation, and the SCIM suite above asserts PostgreSQL rate-limit storage,
269271
# so workflow execution gets its own app rather than adding Redis to that one.
272+
#
273+
# Every app in this job starts from an empty Turbopack dev cache (.next/dev).
274+
# Restoring the one the previous app left, built with other NEXT_PUBLIC_*
275+
# values and cut off by `next dev` killing its server 100ms after SIGTERM,
276+
# panicked Turbopack ("inner_of_upper_lost_follower") or wedged the first
277+
# route compile in 12 of 175 CI runs.
270278
- name: Verify single-block workflow runs over real HTTP
271279
working-directory: apps/sim
272280
env:
@@ -283,17 +291,19 @@ jobs:
283291
report_dir="$RUNNER_TEMP/e2e"
284292
server_log="$report_dir/stop-after-next.log"
285293
mkdir -p "$report_dir"
294+
rm -rf .next/dev
286295
node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3018 > "$server_log" 2>&1 &
287296
server_pid=$!
288297
finish() {
298+
status=$?
289299
kill "$server_pid" 2>/dev/null || true
290300
wait "$server_pid" 2>/dev/null || true
291301
awk '/^ (GET|POST|PUT|PATCH|DELETE|HEAD) \/api\// { print }' "$server_log" > "$report_dir/stop-after-http-status.log"
302+
[ "$status" -eq 0 ] || tail -n 200 "$server_log"
292303
}
293304
trap finish EXIT
294305
fail_startup() {
295306
echo "::error::$1"
296-
tail -n 200 "$server_log"
297307
exit 1
298308
}
299309
started=$SECONDS

‎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+
status = response.status
260+
text = await readResponseTextWithLimit(response, {
261+
maxBytes: MAX_RESPONSE_BYTES,
262+
label: 'Stop-after E2E response',
263+
})
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)