Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,10 @@ jobs:

# The suite, split by file across four Linux runners.
#
# `bun test --shard=i/N` sorts test files by path and deals them round-robin,
# so the split is deterministic for the files that remain in this lane.
# `scripts/ci/run-bun-test-batches.sh` mirrors Bun's sorted round-robin shard
# assignment, then runs each shard in small batches so every batch gets a fresh
# Bun process. The helper prints the exact files before each batch and retries
# only a Bun runtime crash once; ordinary test failures are never retried.
Comment on lines +236 to +239

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document timeout retries.

Lines 238-239 state that the helper retries only a Bun runtime crash. scripts/ci/run-bun-test-batches.sh also retries a singleton file once after a timeout. Update this text to describe both runtime-crash and timeout recovery. Otherwise, CI operators can misdiagnose an expected retry as unexpected behavior.

Proposed fix
-  # Bun process. The helper prints the exact files before each batch and retries
-  # only a Bun runtime crash once; ordinary test failures are never retried.
+  # Bun process. The helper prints the exact files before each batch and isolates
+  # runtime crashes and timeouts per file. Ordinary test failures are never retried.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# `scripts/ci/run-bun-test-batches.sh` mirrors Bun's sorted round-robin shard
# assignment, then runs each shard in small batches so every batch gets a fresh
# Bun process. The helper prints the exact files before each batch and retries
# only a Bun runtime crash once; ordinary test failures are never retried.
# `scripts/ci/run-bun-test-batches.sh` mirrors Bun's sorted round-robin shard
# assignment, then runs each shard in small batches so every batch gets a fresh
# Bun process. The helper prints the exact files before each batch and isolates
# runtime crashes and timeouts per file. Ordinary test failures are never retried.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 236 - 239, Update the workflow comment
describing scripts/ci/run-bun-test-batches.sh to document both one-time Bun
runtime-crash recovery and the one-time retry for singleton files after a
timeout, while preserving the statement that ordinary test failures are not
retried.

# Storage-policy API tests and api-usage are deliberately excluded here and run
# in dedicated jobs below. Bun 1.3.14 can corrupt the Linux isolate/epoll state
# around those Worker-heavy harnesses; keeping them out of the general shards
Expand Down Expand Up @@ -293,8 +295,10 @@ jobs:
cd gui
bun run build

- name: Test
run: bun test --isolate tests --path-ignore-patterns 'tests/api-storage-policy*.test.ts' --path-ignore-patterns 'tests/api-storage.test.ts' --path-ignore-patterns 'tests/api-usage.test.ts' --shard=${{ matrix.shard }}/4
- name: Test in fresh-process batches
env:
TEST_SHARD: ${{ matrix.shard }}/4
run: bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD"

# Bun 1.3.14 has shown a Linux isolate/epoll race around the storage-policy
# harness. Keep the entire six-file family in one fresh process so a runtime
Expand Down
227 changes: 227 additions & 0 deletions scripts/ci/run-bun-test-batches.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
#!/usr/bin/env bash
set -euo pipefail

readonly SHARD_SPEC="${1:-}"
readonly BATCH_SIZE="${BUN_TEST_BATCH_SIZE:-12}"
readonly BATCH_TIMEOUT_SECONDS="${BUN_TEST_BATCH_TIMEOUT_SECONDS:-120}"
readonly BATCH_KILL_GRACE_SECONDS="${BUN_TEST_BATCH_KILL_GRACE_SECONDS:-15}"

usage() {
echo "usage: $0 <shard/total>" >&2
exit 64
}

if [[ ! "$SHARD_SPEC" =~ ^([1-9][0-9]*)/([1-9][0-9]*)$ ]]; then
usage
fi

readonly SHARD_INDEX="${BASH_REMATCH[1]}"
readonly SHARD_COUNT="${BASH_REMATCH[2]}"

if (( SHARD_INDEX > SHARD_COUNT )); then
usage
fi
if [[ ! "$BATCH_SIZE" =~ ^[1-9][0-9]*$ ]]; then
echo "BUN_TEST_BATCH_SIZE must be a positive integer, got: $BATCH_SIZE" >&2
exit 64
fi
if [[ ! "$BATCH_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]]; then
echo "BUN_TEST_BATCH_TIMEOUT_SECONDS must be a positive integer, got: $BATCH_TIMEOUT_SECONDS" >&2
exit 64
fi
if [[ ! "$BATCH_KILL_GRACE_SECONDS" =~ ^[1-9][0-9]*$ ]]; then
echo "BUN_TEST_BATCH_KILL_GRACE_SECONDS must be a positive integer, got: $BATCH_KILL_GRACE_SECONDS" >&2
exit 64
fi
if ! command -v timeout >/dev/null 2>&1; then
echo "GNU timeout is required to bound Bun test batches." >&2
exit 69
fi

is_general_test_file() {
local path="$1"

case "$path" in
tests/api-storage-policy*.test.ts|tests/api-storage.test.ts|tests/api-usage.test.ts)
return 1
;;
esac

case "$path" in
*.test.js|*.test.jsx|*.test.ts|*.test.tsx|*_test.js|*_test.jsx|*_test.ts|*_test.tsx|*.spec.js|*.spec.jsx|*.spec.ts|*.spec.tsx|*_spec.js|*_spec.jsx|*_spec.ts|*_spec.tsx)
return 0
;;
*)
return 1
;;
esac
}

is_bun_runtime_crash() {
local status="$1"
local log_file="$2"

case "$status" in
132|133|134|135|136|137|139)
return 0
;;
esac

grep -Eqi \
'oh no: Bun has crashed|Segmentation fault at address|Illegal instruction|Bus error|Aborted \(core dumped\)' \
"$log_file"
}

LAST_FAILURE_KIND=""

run_test_once() {
local batch_number="$1"
local phase="$2"
local attempt="$3"
shift 3
local -a files=("$@")
local log_file
local status
local label="shard ${SHARD_SPEC} batch ${batch_number}/${TOTAL_BATCHES}"

if [[ -n "$phase" ]]; then
label+=" ${phase}"
fi

log_file="$(mktemp -t ocx-bun-test-batch.XXXXXX)"

echo "::group::${label} attempt ${attempt} (${#files[@]} files)"
printf ' %s\n' "${files[@]}"

set +e
timeout --signal=TERM --kill-after="${BATCH_KILL_GRACE_SECONDS}s" \
"${BATCH_TIMEOUT_SECONDS}s" \
bun test --isolate "${files[@]}" 2>&1 | tee "$log_file"
status="${PIPESTATUS[0]}"
set -e

echo "::endgroup::"

if (( status == 0 )); then
LAST_FAILURE_KIND=""
rm -f -- "$log_file"
return 0
fi

if (( status == 124 )); then
LAST_FAILURE_KIND="timeout"
echo "::warning::Bun test process timed out after ${BATCH_TIMEOUT_SECONDS}s in ${label} (attempt ${attempt})."
rm -f -- "$log_file"
return "$status"
fi

if is_bun_runtime_crash "$status" "$log_file"; then
LAST_FAILURE_KIND="runtime"
echo "::warning::Bun runtime crash in ${label} (exit ${status}, attempt ${attempt})."
rm -f -- "$log_file"
return "$status"
fi

LAST_FAILURE_KIND="test"
echo "::error::Test failure in ${label} (exit ${status}); not retrying assertion/test failures."
rm -f -- "$log_file"
return "$status"
}

recover_batch_file_by_file() {
local batch_number="$1"
local batch_failure_kind="$2"
shift 2
local -a files=("$@")
local file
local file_index=0
local status
local retry_kind

echo "::warning::Shard ${SHARD_SPEC} batch ${batch_number} hit a ${batch_failure_kind}; rerunning its ${#files[@]} files one at a time in fresh Bun processes."

for file in "${files[@]}"; do
((file_index += 1))
if run_test_once "$batch_number" "singleton ${file_index}/${#files[@]}" 1 "$file"; then
continue
else
status=$?
fi

if [[ "$LAST_FAILURE_KIND" != "runtime" && "$LAST_FAILURE_KIND" != "timeout" ]]; then
echo "::error::Singleton isolation identified ${file} as a failing test file."
return "$status"
fi

retry_kind="$LAST_FAILURE_KIND"
echo "Retrying ${file} once in another fresh Bun process after ${retry_kind} failure..."
if run_test_once "$batch_number" "singleton ${file_index}/${#files[@]}" 2 "$file"; then
echo "::warning::${file} passed on its single ${retry_kind} retry."
continue
else
status=$?
fi

if [[ "$LAST_FAILURE_KIND" == "timeout" ]]; then
echo "::error::${file} timed out twice under singleton isolation; failing after one retry."
elif [[ "$LAST_FAILURE_KIND" == "runtime" ]]; then
echo "::error::Bun runtime crash repeated for ${file} under singleton isolation; failing after one retry."
else
echo "::error::${file} failed during singleton retry."
fi
return "$status"
done

echo "::warning::Shard ${SHARD_SPEC} batch ${batch_number} passed under singleton isolation after the original ${batch_failure_kind}; continuing."
return 0
}

mapfile -d '' -t ALL_TEST_FILES < <(
find tests -type f -print0 \
| LC_ALL=C sort -z
)

SELECTED_FILES=()
general_index=0
for path in "${ALL_TEST_FILES[@]}"; do
if ! is_general_test_file "$path"; then
continue
fi

if (( general_index % SHARD_COUNT == SHARD_INDEX - 1 )); then
SELECTED_FILES+=("$path")
fi
((general_index += 1))
done

if (( ${#SELECTED_FILES[@]} == 0 )); then
echo "No tests selected for shard ${SHARD_SPEC}." >&2
exit 1
fi

readonly TOTAL_BATCHES=$(( (${#SELECTED_FILES[@]} + BATCH_SIZE - 1) / BATCH_SIZE ))
echo "Shard ${SHARD_SPEC}: ${#SELECTED_FILES[@]} files in ${TOTAL_BATCHES} primary Bun processes (batch size <= ${BATCH_SIZE}, timeout ${BATCH_TIMEOUT_SECONDS}s)."
echo "Runtime crashes and timeouts fall back to one-file-per-process isolation; assertion/test failures do not retry."

for ((batch_index = 0; batch_index < TOTAL_BATCHES; batch_index += 1)); do
start=$(( batch_index * BATCH_SIZE ))
batch=("${SELECTED_FILES[@]:start:BATCH_SIZE}")
batch_number=$(( batch_index + 1 ))

if run_test_once "$batch_number" "" 1 "${batch[@]}"; then
continue
else
status=$?
fi

if [[ "$LAST_FAILURE_KIND" != "runtime" && "$LAST_FAILURE_KIND" != "timeout" ]]; then
exit "$status"
fi

failure_kind="$LAST_FAILURE_KIND"
if recover_batch_file_by_file "$batch_number" "$failure_kind" "${batch[@]}"; then
continue
else
exit $?
fi
done
13 changes: 9 additions & 4 deletions tests/zz-ci-api-usage-isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@ test("Linux shards isolate api-usage into its own gated job", async () => {
jobs?: Record<string, Job>;
};

const shardRun = workflow.jobs?.test?.steps?.find(step => step.name === "Test")?.run ?? "";
expect(shardRun).toContain(
"--path-ignore-patterns 'tests/api-usage.test.ts'",
);
const shardRun = workflow.jobs?.test?.steps?.find(
step => step.name === "Test in fresh-process batches",
)?.run ?? "";
expect(shardRun).toContain("scripts/ci/run-bun-test-batches.sh");

const batchHelper = await Bun.file(
new URL("../scripts/ci/run-bun-test-batches.sh", import.meta.url),
).text();
expect(batchHelper).toContain("tests/api-usage.test.ts)");
Comment on lines +28 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the exclusion result, not only filename text.

These assertions pass if the filename appears in a comment or if the matching case branch returns 0. A regression can then run dedicated tests in general shards and remove the intended isolation.

  • tests/zz-ci-api-usage-isolation.test.ts#L28-L31: assert that the tests/api-usage.test.ts branch returns 1.
  • tests/zz-ci-storage-policy-isolation.test.ts#L28-L32: assert that the storage-policy patterns are in the exclusion branch that returns 1.
Proposed fix
-  expect(batchHelper).toContain("tests/api-usage.test.ts)");
+  expect(batchHelper).toMatch(
+    /tests\/api-usage\.test\.ts\)\s*\n\s*return 1/,
+  );
-  expect(batchHelper).toContain("tests/api-storage-policy*.test.ts");
-  expect(batchHelper).toContain("tests/api-storage.test.ts");
+  expect(batchHelper).toMatch(
+    /tests\/api-storage-policy\*\.test\.ts\|tests\/api-storage\.test\.ts\|tests\/api-usage\.test\.ts\)\s*\n\s*return 1/,
+  );
📍 Affects 2 files
  • tests/zz-ci-api-usage-isolation.test.ts#L28-L31 (this comment)
  • tests/zz-ci-storage-policy-isolation.test.ts#L28-L32
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/zz-ci-api-usage-isolation.test.ts` around lines 28 - 31, Update the
assertions in tests/zz-ci-api-usage-isolation.test.ts lines 28-31 to verify that
the tests/api-usage.test.ts matching branch returns 1, rather than only checking
filename text. Update tests/zz-ci-storage-policy-isolation.test.ts lines 28-32
to verify that its storage-policy patterns are within the exclusion branch
returning 1; both sites require direct assertion changes.


const apiUsageJob = workflow.jobs?.["api-usage"];
expect(apiUsageJob?.["runs-on"]).toBe("ubuntu-latest");
Expand Down
17 changes: 10 additions & 7 deletions tests/zz-ci-storage-policy-isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@ test("Linux shards isolate the storage API runtime family into its own gated job
jobs?: Record<string, Job>;
};

const shardRun = workflow.jobs?.test?.steps?.find(step => step.name === "Test")?.run ?? "";
expect(shardRun).toContain(
"--path-ignore-patterns 'tests/api-storage-policy*.test.ts'",
);
expect(shardRun).toContain(
"--path-ignore-patterns 'tests/api-storage.test.ts'",
);
const shardRun = workflow.jobs?.test?.steps?.find(
step => step.name === "Test in fresh-process batches",
)?.run ?? "";
expect(shardRun).toContain("scripts/ci/run-bun-test-batches.sh");

const batchHelper = await Bun.file(
new URL("../scripts/ci/run-bun-test-batches.sh", import.meta.url),
).text();
expect(batchHelper).toContain("tests/api-storage-policy*.test.ts");
expect(batchHelper).toContain("tests/api-storage.test.ts");

const storageJob = workflow.jobs?.["storage-policy"];
expect(storageJob?.["runs-on"]).toBe("ubuntu-latest");
Expand Down
Loading